Compare commits
34 Commits
464f5ec62b
...
1bc6ebe534
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bc6ebe534 | ||
|
|
4f055a80d0 | ||
|
|
0dba6b7672 | ||
|
|
5a5914226f | ||
|
|
a09b794302 | ||
|
|
3c37e851e9 | ||
|
|
e221a18b46 | ||
|
|
181dd65958 | ||
|
|
d7f82cb611 | ||
|
|
f3ac1eb5c2 | ||
|
|
29f6b30752 | ||
|
|
cc0026bb1c | ||
|
|
acbc19c7eb | ||
|
|
6216039526 | ||
|
|
47495fa5db | ||
|
|
a67ce091e1 | ||
|
|
600163a9ee | ||
|
|
34d5714898 | ||
|
|
2558248dc9 | ||
|
|
68b73439a6 | ||
|
|
f8867939d5 | ||
|
|
b41ab0ad25 | ||
|
|
f353d90ec6 | ||
|
|
68018c3e5e | ||
|
|
ccf519c9e7 | ||
|
|
1fd64e974d | ||
|
|
a19c1ca7e1 | ||
|
|
8314f2fb94 | ||
|
|
3394ddcf4c | ||
|
|
e981d62620 | ||
|
|
f082541566 | ||
|
|
81850783d4 | ||
|
|
ff0f2d1077 | ||
|
|
0dcf0ac5cc |
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,3 +7,7 @@ data.json
|
||||
tests/fixtures/*.m4a
|
||||
tests/fixtures/*.webm
|
||||
.DS_Store
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.sqlite*
|
||||
.pytest_cache/
|
||||
|
||||
@ -1,15 +1,39 @@
|
||||
# Архитектура
|
||||
|
||||
{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}}
|
||||
Хаб моста Jira: SQLite-зеркало задач AURORA/AWPCA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает.
|
||||
|
||||
## Модули
|
||||
|
||||
| Модуль | Ответственность | Зависит от |
|
||||
|---|---|---|
|
||||
| | | |
|
||||
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/` | stdlib |
|
||||
| `db` | схема SQLite, миграции, `connect` | stdlib |
|
||||
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
|
||||
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` |
|
||||
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
|
||||
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
|
||||
| `backup` | копии файла базы, ротация | stdlib |
|
||||
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup` |
|
||||
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
|
||||
| `mcp_server` | инструменты MCP по stdio | `client` |
|
||||
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
|
||||
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` |
|
||||
|
||||
Цепочки: `api → queue → commands/mirror/settings → db`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
|
||||
|
||||
## Потоки данных
|
||||
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
|
||||
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
|
||||
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
|
||||
|
||||
## Границы
|
||||
- Что не должно импортировать что.
|
||||
- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят.
|
||||
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
|
||||
- `queue` не повторяет команды: нет автоматических ретраев, повтор — новый пакет.
|
||||
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`.
|
||||
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api` и `client`.
|
||||
|
||||
## Открытые вопросы
|
||||
-
|
||||
- Просмотрщик на живых данных и правки из UI — отдельные change'и (`viewer-live`, `viewer-edit`).
|
||||
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
|
||||
- Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`).
|
||||
|
||||
29
CLAUDE.md
29
CLAUDE.md
@ -1,28 +1,35 @@
|
||||
# jira-hub
|
||||
|
||||
## Что это
|
||||
{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}}
|
||||
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спека: `openspec/changes/bridge-core/`.
|
||||
|
||||
## Стек и ограничения
|
||||
- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}}
|
||||
- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}}
|
||||
- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
|
||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||
- Хаб не повторяет команды сам: повтор — новая команда по запросу человека/интерфейса.
|
||||
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`. Порт по умолчанию 8790.
|
||||
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
|
||||
|
||||
## Архитектура
|
||||
- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью.
|
||||
- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно.
|
||||
- {{Ключевые интерфейсы/порты между ними, если есть.}}
|
||||
- `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром.
|
||||
- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`) ← `cli`.
|
||||
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api`.
|
||||
- Подробная карта модулей и границ — `ARCHITECTURE.md`.
|
||||
|
||||
## Команды
|
||||
- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор.
|
||||
- `{{npm run build / ...}}` — сборка.
|
||||
- `{{npm run dev / ...}}` — локальный запуск.
|
||||
- `uv run pytest` — весь набор тестов. Зелёным считается только весь набор.
|
||||
- `uv sync` — установка окружения.
|
||||
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
|
||||
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба.
|
||||
- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены.
|
||||
|
||||
## Тесты
|
||||
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
|
||||
- {{Где лежит тестовое окружение (vault, БД, песочница).}}
|
||||
- SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют.
|
||||
|
||||
## Первая подсистема
|
||||
{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}}
|
||||
Очередь команд (`queue.py`, Task 5): пакеты, порядок `(batch.created_at, seq)`, одна команда в полёте, временные ключи `new:<n>`, подтверждение по состоянию зеркала. Остальное (API, MCP, импорт) надстраивается над ней.
|
||||
|
||||
## OpenSpec и superpowers
|
||||
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.
|
||||
|
||||
174
README.md
Normal file
174
README.md
Normal file
@ -0,0 +1,174 @@
|
||||
# jira-hub
|
||||
|
||||
Хаб моста Jira для АРМ КА: зеркало задач проекта AURORA (метка AWPCA) в SQLite, служебные заметки, очередь команд,
|
||||
HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только
|
||||
агент на виртуалке (репозиторий `jira-agent`), пароль Jira хабу неизвестен.
|
||||
Спека: `openspec/changes/bridge-core/` (proposal, design, specs).
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
Данные лежат в `~/.local/share/jira-hub/` (каталог переопределяется переменной `JIRA_HUB_HOME`):
|
||||
`hub.sqlite` и `backups/`. Пакет `mcp` закреплён версией `<2` (рулинг R-10): MCP SDK 2.x ломает API.
|
||||
|
||||
## Первый запуск
|
||||
|
||||
```bash
|
||||
uv run jira-hub init --config ../admin/jira-sync/config.example.json
|
||||
uv run jira-hub serve # 0.0.0.0:8790
|
||||
```
|
||||
|
||||
`init` переносит в настройки хаба всё, что относится к проекту Jira (поля, типы, карту переходов, типы связей, доску,
|
||||
приоритеты), и создаёт два токена: агентский и клиентский. Адрес и учётные данные Jira (`base_url`, `auth`, `verify_ssl`,
|
||||
`ca_bundle`) остаются у агента и в хаб не попадают. Повторный `init` отказывает ("уже инициализирован"); с `--force`
|
||||
настройки переписываются. Токены можно показать позже: `uv run jira-hub tokens`.
|
||||
|
||||
Передать агенту нужно адрес хаба и агентский токен: через общую папку виртуалки положить в `agent.json`
|
||||
`"hub_url": "http://192.168.0.106:8790"` и `"hub_token": "<агентский токен>"`. Клиентский токен остаётся на этой машине:
|
||||
его использует MCP (читает из базы сам).
|
||||
|
||||
Порт меняется ключом `--port` или настройкой `port`; адрес прослушивания — `--host`.
|
||||
|
||||
## Импорт пакета admin/jira-sync
|
||||
|
||||
```bash
|
||||
uv run jira-hub import ../admin/jira-sync
|
||||
```
|
||||
|
||||
Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON:
|
||||
`notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и
|
||||
`missing_links` (ожидаемые связи, которых нет). Запускать после первого `refresh` с `full: true`: пока зеркало пусто,
|
||||
все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются.
|
||||
|
||||
## MCP в Claude
|
||||
|
||||
`jira-hub mcp` запускает MCP-сервер по stdio; он ходит в API хаба клиентским токеном. Если хаб не запущен, инструменты
|
||||
отвечают «хаб не запущен: jira-hub serve». Регистрация вручную (файл `~/.claude.json` скрипты хаба не правят),
|
||||
блок для проекта `awp-ca`:
|
||||
|
||||
```json
|
||||
"jira-hub": {"type": "stdio", "command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/awp-ca/jira-hub", "jira-hub", "mcp"]}
|
||||
```
|
||||
|
||||
Либо командой: `claude mcp add jira-hub -- uv run --project /Users/andrey.epifancev/projects/awp-ca/jira-hub jira-hub mcp`.
|
||||
|
||||
| Инструмент | Параметры | Результат |
|
||||
|---|---|---|
|
||||
| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач |
|
||||
| `issue` | `key` | поля, связи, заметки, последние команды |
|
||||
| `notes_set` | `key`, поля заметок | обновлённые заметки |
|
||||
| `batch_create` | `title`, `commands` | `id`, оценка длительности, число команд |
|
||||
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
|
||||
| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту; ожидание асинхронное, сервер не блокируется |
|
||||
| `batches` | `limit` | последние пакеты |
|
||||
| `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены |
|
||||
| `refresh` | `keys` или `full`, `wait` (по умолчанию `false`: только ставит пакет; `true` — ждёт до 30 мин) | id пакета или итог пакета |
|
||||
| `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки |
|
||||
| `sync_log` | `limit` | последние загрузки состояния |
|
||||
| `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` |
|
||||
|
||||
Автор команд из MCP: `claude`; из API без автора: `api`.
|
||||
|
||||
## HTTP API
|
||||
|
||||
Все маршруты требуют `Authorization: Bearer <токен>`; ответы JSON, ошибки `{error, detail}` (400/401/404/409/422).
|
||||
|
||||
| Маршрут | Токен | Назначение |
|
||||
|---|---|---|
|
||||
| `POST /agent/claim` | агент | взять следующую команду (долгий запрос до 30 с) |
|
||||
| `GET /agent/settings` | агент | настройки для агента (без токенов) |
|
||||
| `GET /agent/commands/{id}` | агент | команда по id |
|
||||
| `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) |
|
||||
| `POST /agent/state` | агент | загрузка состояния задач пачками |
|
||||
| `GET /agent/known-keys` | агент | ключи зеркала |
|
||||
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
|
||||
| `GET /issues/{key}` | клиент | задача целиком |
|
||||
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
|
||||
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд |
|
||||
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
|
||||
| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) |
|
||||
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` |
|
||||
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента, журнал загрузок |
|
||||
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) |
|
||||
|
||||
```bash
|
||||
TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p')
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=Спринт%202&limit=20"
|
||||
```
|
||||
|
||||
## Контракт команд
|
||||
|
||||
Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же
|
||||
результатом завершается `done` без записи.
|
||||
|
||||
| Тип | Параметры |
|
||||
|---|---|
|
||||
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
|
||||
| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` |
|
||||
| `ensure_comment` | `key`, `marker`, `body` |
|
||||
| `ensure_status` | `key`, `target` (имя статуса или список-путь) |
|
||||
| `ensure_link` | `source`, `target`, `link_type`, `replace` |
|
||||
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) |
|
||||
| `refresh` | `keys` или `jql` |
|
||||
|
||||
Ключ в параметре может быть временным `new:<n>`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же
|
||||
пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`):
|
||||
|
||||
```json
|
||||
{"title": "1538 в спринт 2", "author": "claude", "commands": [
|
||||
{"type": "ensure_fields", "params": {"key": "AURORA-1538", "duedate": "2026-10-23"}},
|
||||
{"type": "ensure_link", "params": {"source": "AURORA-1537", "target": "AURORA-1538", "link_type": "связанные", "replace": true}},
|
||||
{"type": "ensure_sprint", "params": {"key": "AURORA-1538", "sprint": "Спринт 2"}}
|
||||
]}
|
||||
```
|
||||
|
||||
Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с,
|
||||
`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30 с, `refresh` 2 с (настройка `pacing`).
|
||||
|
||||
## Пример сценария
|
||||
|
||||
1. `jira-hub serve` на Mac mini, агент запущен на виртуалке.
|
||||
2. В Claude: `refresh` с `full` загружает задачи в зеркало; `jira-hub import` переносит заметки из CSV.
|
||||
3. `batch_create` ставит пакет; `batch_wait` ждёт результата; `issue` показывает итог.
|
||||
|
||||
## Эксплуатация
|
||||
|
||||
- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-<batch>.sqlite`
|
||||
и хранит последние `backup_keep` копий (по умолчанию 30).
|
||||
- Автообновление: если `auto_refresh_minutes > 0` (по умолчанию 60), агент на связи и очередь пуста дольше интервала,
|
||||
хаб ставит пакет «Автообновление».
|
||||
- Настройки: `PUT /settings` или инструмент `settings_set`; защищены `settings_version`, `schema_version`, `tokens`.
|
||||
- Команда «нет ответа от агента»: команда в `claimed` без отчёта. Дождитесь агента (перезапущенный агент отчитается) или
|
||||
отмените: `POST /commands/{id}/cancel` с `{"force": true}`. Хаб сам команды не повторяет; если факт уже виден после
|
||||
`refresh`, команда становится `confirmed_by_state`. Повтор: `command_retry`.
|
||||
- Автозапуск `serve` через launchd: `~/Library/LaunchAgents/ru.aepif.jira-hub.plist`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>ru.aepif.jira-hub</string>
|
||||
<key>ProgramArguments</key><array>
|
||||
<string>uv</string><string>run</string><string>--project</string>
|
||||
<string>/Users/andrey.epifancev/projects/awp-ca/jira-hub</string>
|
||||
<string>jira-hub</string><string>serve</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key><dict>
|
||||
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>StandardErrorPath</key><string>/tmp/jira-hub.err.log</string>
|
||||
</dict></plist>
|
||||
```
|
||||
|
||||
Загрузить: `launchctl load ~/Library/LaunchAgents/ru.aepif.jira-hub.plist`.
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
@ -64,3 +64,16 @@ Non-goals — см. proposal.md.
|
||||
| 2026-10-11 | Хранение базы и настроек хаба — `~/.local/share/jira-hub/` | вне git, переживает пересоздание worktree | — |
|
||||
| 2026-10-11 | Регистрация MCP — в `~/.claude.json` для проекта `awp-ca` (stdio, `uv run jira-hub mcp`) | как остальные MCP этого проекта | — |
|
||||
| 2026-10-11 | Полная синхронизация по расписанию: хаб ставит `refresh` по базовой JQL раз в 60 мин, если агент на связи и очередь пуста | свежее зеркало без участия человека | лишние чтения; интервал в настройках, 0 отключает |
|
||||
| 2026-10-11 | R-1: Review Focus #5 плана ссылается на Task 7, тест `test_settings_put_rejects_protected` лежит в Task 6 — тест остаётся в Task 6 | опечатка плана; спека о месте теста молчит | нулевая |
|
||||
| 2026-10-11 | R-2: T3.9 (транзакции на общем соединении между потоками) — не дефект; в `set_note` правится только вводящий в заблуждение комментарий; в Task 6 проверить, что API открывает соединение на запрос | Architecture плана: каждый запрос открывает своё соединение SQLite (WAL, busy_timeout) | если соединение будут делить потоки — ошибки вложенного BEGIN; чинится локом в db.transaction |
|
||||
| 2026-10-11 | R-3: `commands.check_confirmed(conn, cmd, link_types, reporter=None)` — необязательный фильтр по `reporter` для `ensure_issue`; Task 5 берёт значение из настройки `reporter`, если она задана | спека требует «задача с той же темой от нас», а хаб не знает логин агента | без настройки возможно ложное `confirmed_by_state` чужой задачей с той же темой за 7 дней (только при потерянном отчёте) |
|
||||
| 2026-10-11 | R-4: временный ключ `new:<n>` должен быть объявлен `ensure_issue` раньше по списку пакета (строже спеки «вне пакета») | иначе зависимая команда с меньшим `seq` блокирует очередь | при реальной потребности в обратном порядке — снять проверку и сортировать по зависимостям |
|
||||
| 2026-10-11 | R-5: `db.transaction` становится вложенной (если `conn.in_transaction` — блок входит во внешнюю транзакцию без своих BEGIN/COMMIT) | после исправления T3.1/T3.2 `mirror.rename_note`/`set_note` сами открывают транзакцию, а `queue.report_result` по плану вызывает их внутри своей — вложенный `BEGIN IMMEDIATE` падал бы | вложенный блок не откатывается отдельно от внешнего (savepoint не нужен: ошибка откатывает всю внешнюю транзакцию) |
|
||||
| 2026-10-11 | R-6: сбой резервной копии в `queue.claim` не блокирует выдачу команды: исключение ловится, пишется предупреждение в лог | копия защищает базу хаба и не является условием записи в Jira; иначе команда повисает в `claimed` на 10 минут | при сбое диска команда выполнится без свежей копии; строгий вариант — откат claim |
|
||||
| 2026-10-11 | R-7 (уточняет R-5): вложенный `db.transaction` использует `SAVEPOINT`; соединение не делится между потоками | внутренний блок, чья ошибка перехвачена вызывающим, не должен фиксировать частичные записи | лишние SAVEPOINT на каждый вложенный вызов — дёшево |
|
||||
| 2026-10-11 | R-8: T6.16 считается закрытой по формам тела; недостижимая ветка `except ValueError` в `h_settings_put` и широкое имя теста — minor, правятся в финальной волне | protected-ключи отсекаются предпроверкой, `settings.set_` значения не валидирует | введённое в заблуждение имя теста; ввод невалидных значений настроек (T6.22) остаётся возможным |
|
||||
| 2026-10-11 | R-9: повторный импорт CSV передаёт в `set_note` только непустые значения; пустая ячейка не затирает существующее поле заметки | иначе повторный запуск `jira-hub import` молча стирает правки владельца, внесённые в хабе | поле заметки нельзя очистить повторным импортом (очистка — через `notes_set`) |
|
||||
| 2026-10-11 | R-10: зависимость `mcp>=1.2,<2` (закреплена исполнителем Task 8) | `uv` ставит mcp 2.3.0, где `mcp.server.fastmcp` удалён (FastMCP стал MCPServer); код плана написан под FastMCP | позже нужна миграция на MCPServer; пока ограничение исключает неожиданный ломающий апгрейд |
|
||||
| 2026-10-11 | R-11 (заменяет R-3): `ensure_issue` не подтверждается по состоянию, пока автор («мы») неизвестен — `check_confirmed` возвращает `(False, None)` при `reporter is None`; настройка `reporter` не вводится | логин Jira в настройках хаба запрещён спекой data-model; без фильтра по автору подтверждение находило чужую задачу с той же темой (запись в чужую задачу Jira) | потерянный отчёт по `ensure_issue` не лечится сверкой, только восстановлением агента по журналу; follow-up: агент передаёт `jira_user` в `claim` |
|
||||
| 2026-10-11 | R-12: инструменты ожидания MCP (`batch_wait`, `refresh`) — `async`, ожидание через `asyncio.sleep`, HTTP через `asyncio.to_thread`; `refresh` по умолчанию `wait=False` | синхронный `time.sleep` до 60 минут блокирует event loop FastMCP (отмена, ping) | сменилось умолчание `refresh` в MCP |
|
||||
| 2026-10-11 | R-13: в выдаче команда, ждущая stale `ensure_issue` (claimed старше 10 минут), пропускается вместе с остатком своего пакета, выдача идёт к следующему пакету | иначе один потерянный отчёт останавливает всю очередь, выход только отменой человеком | пакет с зависшим определителем висит до подтверждения или отмены человеком |
|
||||
|
||||
@ -18,8 +18,8 @@
|
||||
- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала).
|
||||
- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный
|
||||
отчёт по команде в терминальном статусе отвечает 200 без изменений.
|
||||
- `POST /agent/state` `{command_id, issues: [...], links: [...], page, last_page}` — загрузка состояния пачками;
|
||||
хаб обновляет зеркало и `sync_run`.
|
||||
- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния
|
||||
пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`.
|
||||
- `GET /agent/known-keys` — ключи зеркала (для определения соседей).
|
||||
|
||||
#### Scenario: Выдача с подстановкой ключа
|
||||
|
||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[project]
|
||||
name = "jira-hub"
|
||||
version = "0.1.0"
|
||||
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["mcp>=1.2,<2"]
|
||||
|
||||
[project.scripts]
|
||||
jira-hub = "jira_hub.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/jira_hub"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
2
src/jira_hub/__init__.py
Normal file
2
src/jira_hub/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP."""
|
||||
__version__ = "0.1.0"
|
||||
362
src/jira_hub/api.py
Normal file
362
src/jira_hub/api.py
Normal file
@ -0,0 +1,362 @@
|
||||
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
|
||||
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md)."""
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from . import backup, db, mirror, queue, settings
|
||||
from .commands import CommandError
|
||||
from .paths import backups_dir as default_backups_dir
|
||||
from .queue import ClaimedError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
|
||||
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status, error, detail=None):
|
||||
super().__init__(error)
|
||||
self.status, self.error, self.detail = status, error, detail
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self, db_path, backups_dir=None):
|
||||
self.db_path = Path(db_path)
|
||||
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
|
||||
self.claim_wait = 30
|
||||
conn = self.conn()
|
||||
settings.ensure_defaults(conn)
|
||||
conn.close()
|
||||
|
||||
def conn(self):
|
||||
return db.connect(self.db_path)
|
||||
|
||||
def tick(self):
|
||||
"""Один шаг фонового автообновления; любой сбой логируется и не прерывает цикл."""
|
||||
conn = None
|
||||
try:
|
||||
conn = self.conn()
|
||||
queue.maybe_auto_refresh(conn)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("автообновление: сбой тика")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def make_backup(self, label):
|
||||
conn = self.conn()
|
||||
try:
|
||||
backup.make_backup(conn, self.backups_dir, label, int(settings.get(conn, "backup_keep") or 30))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ----------
|
||||
|
||||
def _int(m, i=1):
|
||||
return int(m.group(i))
|
||||
|
||||
|
||||
def _int_param(q, name, default):
|
||||
try:
|
||||
return int(q.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
raise ApiError(400, "bad_param", f"{name}: ожидается целое число")
|
||||
|
||||
|
||||
def _not_found(obj, what):
|
||||
if obj is None:
|
||||
raise ApiError(404, "not_found", what)
|
||||
return obj
|
||||
|
||||
|
||||
def h_claim(hub, conn, m, q, body):
|
||||
deadline = time.monotonic() + hub.claim_wait
|
||||
while True:
|
||||
cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"),
|
||||
backup=hub.make_backup)
|
||||
if cmd or time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(1)
|
||||
return 200, {"command": cmd, "settings_version": settings.get(conn, "settings_version")}
|
||||
|
||||
|
||||
def h_agent_settings(hub, conn, m, q, body):
|
||||
return 200, settings.for_agent(conn)
|
||||
|
||||
|
||||
def h_agent_command(hub, conn, m, q, body):
|
||||
return 200, _not_found(queue.get_command(conn, _int(m)), "command")
|
||||
|
||||
|
||||
def h_agent_result(hub, conn, m, q, body):
|
||||
try:
|
||||
cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"),
|
||||
body.get("jira_calls", 0), body.get("rate_limited", 0))
|
||||
except KeyError:
|
||||
raise ApiError(404, "not_found", "command")
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_status", str(e))
|
||||
return 200, cmd
|
||||
|
||||
|
||||
def h_agent_state(hub, conn, m, q, body):
|
||||
with db.transaction(conn):
|
||||
out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [],
|
||||
authoritative=queue.is_full_refresh(conn, body.get("command_id")))
|
||||
if body.get("command_id"):
|
||||
queue.record_state(conn, body["command_id"], out["issues"], out["links"])
|
||||
return 200, out
|
||||
|
||||
|
||||
def h_known_keys(hub, conn, m, q, body):
|
||||
return 200, {"keys": mirror.known_keys(conn)}
|
||||
|
||||
|
||||
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q")
|
||||
|
||||
|
||||
def h_issues(hub, conn, m, q, body):
|
||||
filters = {k: q.get(k) for k in ISSUE_FILTERS}
|
||||
return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0))}
|
||||
|
||||
|
||||
def h_issue(hub, conn, m, q, body):
|
||||
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
|
||||
|
||||
|
||||
def h_note_put(hub, conn, m, q, body):
|
||||
fields = {k: v for k, v in body.items() if k != "updated_by"}
|
||||
try:
|
||||
return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api")
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_note", str(e))
|
||||
|
||||
|
||||
def h_batches_post(hub, conn, m, q, body):
|
||||
if not body.get("title") or not isinstance(body["title"], str):
|
||||
raise ApiError(422, "bad_batch", "title — непустая строка")
|
||||
if not isinstance(body.get("author") or "api", str):
|
||||
raise ApiError(422, "bad_batch", "author — строка")
|
||||
if not isinstance(body.get("commands"), list):
|
||||
raise ApiError(422, "bad_batch", "commands — список команд")
|
||||
try:
|
||||
return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"))
|
||||
except CommandError as e:
|
||||
raise ApiError(422, "bad_batch", str(e))
|
||||
|
||||
|
||||
def h_batches_get(hub, conn, m, q, body):
|
||||
return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20))}
|
||||
|
||||
|
||||
def h_batch_get(hub, conn, m, q, body):
|
||||
return 200, _not_found(queue.batch_view(conn, _int(m)), "batch")
|
||||
|
||||
|
||||
def h_batch_cancel(hub, conn, m, q, body):
|
||||
try:
|
||||
return 200, queue.cancel_batch(conn, _int(m))
|
||||
except KeyError:
|
||||
raise ApiError(404, "not_found", "batch")
|
||||
|
||||
|
||||
def h_command_retry(hub, conn, m, q, body):
|
||||
if not isinstance(body.get("author") or "api", str):
|
||||
raise ApiError(422, "bad_retry", "author — строка")
|
||||
try:
|
||||
return 201, queue.retry_command(conn, _int(m), body.get("author") or "api")
|
||||
except KeyError:
|
||||
raise ApiError(404, "not_found", "command")
|
||||
except CommandError as e:
|
||||
raise ApiError(422, "bad_batch", str(e))
|
||||
|
||||
|
||||
def h_command_cancel(hub, conn, m, q, body):
|
||||
try:
|
||||
return 200, queue.cancel_command(conn, _int(m), bool(body.get("force")))
|
||||
except KeyError:
|
||||
raise ApiError(404, "not_found", "command")
|
||||
except ClaimedError as e:
|
||||
raise ApiError(409, "claimed", e.claimed_at)
|
||||
|
||||
|
||||
def h_refresh(hub, conn, m, q, body):
|
||||
if not isinstance(body.get("author") or "api", str):
|
||||
raise ApiError(422, "bad_refresh", "author — строка")
|
||||
keys = body.get("keys")
|
||||
if keys is not None and (not isinstance(keys, list) or not all(isinstance(k, str) and k for k in keys)):
|
||||
raise ApiError(422, "bad_refresh", "keys — список строк")
|
||||
if body.get("jql") is not None and not isinstance(body["jql"], str):
|
||||
raise ApiError(422, "bad_refresh", "jql — строка")
|
||||
if body.get("full"):
|
||||
params = {"jql": settings.get(conn, "base_jql")}
|
||||
elif body.get("jql"):
|
||||
params = {"jql": body["jql"]}
|
||||
elif body.get("keys"):
|
||||
params = {"keys": keys}
|
||||
else:
|
||||
raise ApiError(422, "bad_refresh", "нужно full, jql или keys")
|
||||
return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}])
|
||||
|
||||
|
||||
def h_agent_get(hub, conn, m, q, body):
|
||||
return 200, queue.agent_view(conn)
|
||||
|
||||
|
||||
def h_sync_runs(hub, conn, m, q, body):
|
||||
rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (_int_param(q, "limit", 20),)).fetchall()
|
||||
return 200, {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
def h_settings_get(hub, conn, m, q, body):
|
||||
return 200, settings.get_all(conn)
|
||||
|
||||
|
||||
def h_settings_put(hub, conn, m, q, body):
|
||||
"""Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения
|
||||
(settings.validate): ошибка — 422, версия настроек не меняется."""
|
||||
bad = [k for k in body if k in settings.PROTECTED]
|
||||
if bad:
|
||||
raise ApiError(422, "protected", ", ".join(bad))
|
||||
for key, value in body.items():
|
||||
try:
|
||||
settings.validate(key, value)
|
||||
except ValueError as e:
|
||||
raise ApiError(422, "bad_setting", str(e))
|
||||
version = settings.get(conn, "settings_version")
|
||||
with db.transaction(conn):
|
||||
for key, value in body.items():
|
||||
version = settings.set_(conn, key, value)
|
||||
return 200, {"settings_version": version}
|
||||
|
||||
|
||||
ROUTES = [
|
||||
("POST", r"^/agent/claim$", h_claim, "agent"),
|
||||
("GET", r"^/agent/settings$", h_agent_settings, "agent"),
|
||||
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
|
||||
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
|
||||
("POST", r"^/agent/state$", h_agent_state, "agent"),
|
||||
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
|
||||
("GET", r"^/issues$", h_issues, "client"),
|
||||
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
||||
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
|
||||
("POST", r"^/batches$", h_batches_post, "client"),
|
||||
("GET", r"^/batches$", h_batches_get, "client"),
|
||||
("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
|
||||
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
|
||||
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
|
||||
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"),
|
||||
("POST", r"^/refresh$", h_refresh, "client"),
|
||||
("GET", r"^/agent$", h_agent_get, "client"),
|
||||
("GET", r"^/sync-runs$", h_sync_runs, "client"),
|
||||
("GET", r"^/settings$", h_settings_get, "client"),
|
||||
("PUT", r"^/settings$", h_settings_put, "client"),
|
||||
]
|
||||
_COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES]
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
timeout = SOCKET_TIMEOUT
|
||||
hub: Hub = None # подставляется в make_server
|
||||
|
||||
def do_GET(self):
|
||||
self._dispatch("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._dispatch("POST")
|
||||
|
||||
def do_PUT(self):
|
||||
self._dispatch("PUT")
|
||||
|
||||
def log_message(self, fmt, *args): # тише стандартного: без адреса клиента
|
||||
print(f"[{self.log_date_time_string()}] {fmt % args}")
|
||||
|
||||
def _auth(self, scope):
|
||||
token = (self.headers.get("Authorization") or "").removeprefix("Bearer ").strip()
|
||||
conn = self.hub.conn()
|
||||
try:
|
||||
tokens = settings.tokens(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
expected = tokens["agent" if scope == "agent" else "client"]
|
||||
if not token or not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")):
|
||||
raise ApiError(401, "unauthorized", "неверный токен для этого маршрута")
|
||||
|
||||
def _dispatch(self, method):
|
||||
url = urlparse(self.path)
|
||||
try:
|
||||
query = {k: v[0] for k, v in parse_qs(url.query).items()}
|
||||
raw_len = (self.headers.get("Content-Length") or "0").strip()
|
||||
if not raw_len.isascii() or not raw_len.isdigit():
|
||||
raise ApiError(400, "bad_content_length", "Content-Length должен быть неотрицательным целым")
|
||||
length = int(raw_len)
|
||||
if length > MAX_BODY:
|
||||
raise ApiError(413, "too_large", f"тело больше {MAX_BODY} байт")
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
for route_method, rx, fn, scope in _COMPILED:
|
||||
if route_method != method:
|
||||
continue
|
||||
match = rx.match(url.path)
|
||||
if not match:
|
||||
continue
|
||||
self._auth(scope)
|
||||
try:
|
||||
body = json.loads(raw.decode("utf-8")) if raw else {}
|
||||
except ValueError as e: # битый JSON или не UTF-8
|
||||
raise ApiError(400, "bad_json", str(e))
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(400, "bad_json", "ожидается объект")
|
||||
conn = self.hub.conn()
|
||||
try:
|
||||
status, payload = fn(self.hub, conn, match, query, body)
|
||||
finally:
|
||||
conn.close()
|
||||
return self._send(status, payload)
|
||||
raise ApiError(404, "not_found", url.path)
|
||||
except ApiError as e:
|
||||
self._send(e.status, {"error": e.error, "detail": e.detail})
|
||||
except Exception as e: # noqa: BLE001 — любой сбой обработчика виден клиенту и в логе
|
||||
traceback.print_exc()
|
||||
self._send(500, {"error": "internal", "detail": str(e)})
|
||||
|
||||
def _send(self, status, payload):
|
||||
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
def make_server(hub: Hub, host="0.0.0.0", port=0) -> ThreadingHTTPServer:
|
||||
handler = type("HubHandler", (Handler,), {"hub": hub})
|
||||
return ThreadingHTTPServer((host, port), handler)
|
||||
|
||||
|
||||
def serve(hub: Hub, host="0.0.0.0", port=8790) -> None:
|
||||
srv = make_server(hub, host, port)
|
||||
stop = threading.Event()
|
||||
|
||||
def ticker():
|
||||
while not stop.wait(60):
|
||||
hub.tick()
|
||||
|
||||
threading.Thread(target=ticker, daemon=True).start()
|
||||
print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}")
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
stop.set()
|
||||
srv.server_close()
|
||||
15
src/jira_hub/backup.py
Normal file
15
src/jira_hub/backup.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Резервная копия файла базы перед пакетом с записями в Jira: VACUUM INTO даёт согласованный снимок при WAL."""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def make_backup(conn, backups_dir: Path, label: str, keep: int = 30) -> Path:
|
||||
backups_dir = Path(backups_dir)
|
||||
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = backups_dir / f"{datetime.now():%Y%m%d-%H%M%S-%f}-{label}.sqlite"
|
||||
conn.execute("VACUUM INTO ?", (str(target),))
|
||||
files = sorted(backups_dir.glob("*.sqlite"))
|
||||
if keep > 0:
|
||||
for old in files[:-keep]:
|
||||
old.unlink()
|
||||
return target
|
||||
68
src/jira_hub/cli.py
Normal file
68
src/jira_hub/cli.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens."""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from . import db, importer, paths, settings
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены")
|
||||
p.add_argument("--config", required=True)
|
||||
p.add_argument("--force", action="store_true")
|
||||
p = sub.add_parser("serve", help="запустить HTTP API")
|
||||
p.add_argument("--host", default="0.0.0.0")
|
||||
p.add_argument("--port", type=int)
|
||||
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
|
||||
p.add_argument("folder")
|
||||
sub.add_parser("mcp", help="MCP-сервер по stdio")
|
||||
sub.add_parser("tokens", help="показать токены агента и клиента")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.cmd == "serve":
|
||||
from . import api
|
||||
hub = api.Hub(paths.db_path())
|
||||
conn = hub.conn()
|
||||
port = args.port or int(settings.get(conn, "port") or 8790)
|
||||
conn.close()
|
||||
api.serve(hub, args.host, port)
|
||||
return 0
|
||||
if args.cmd == "mcp":
|
||||
from . import mcp_server
|
||||
mcp_server.run()
|
||||
return 0
|
||||
|
||||
conn = db.connect(paths.db_path())
|
||||
settings.ensure_defaults(conn)
|
||||
try:
|
||||
if args.cmd == "init":
|
||||
with open(args.config, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
try:
|
||||
keys = settings.init_from_config(conn, cfg, force=args.force)
|
||||
except RuntimeError as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
print("перенесено: " + ", ".join(keys))
|
||||
_print_tokens(conn)
|
||||
return 0
|
||||
if args.cmd == "import":
|
||||
print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1))
|
||||
return 0
|
||||
if args.cmd == "tokens":
|
||||
_print_tokens(conn)
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
return 2
|
||||
|
||||
|
||||
def _print_tokens(conn) -> None:
|
||||
tok = settings.tokens(conn)
|
||||
print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
63
src/jira_hub/client.py
Normal file
63
src/jira_hub/client.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""HTTP-клиент хаба: для MCP, тестов и скриптов. Ошибки API → HubError, недоступный хаб → HubUnavailable."""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import db, paths, settings
|
||||
|
||||
|
||||
class HubError(Exception):
|
||||
def __init__(self, status, error, detail=None):
|
||||
super().__init__(f"HTTP {status} {error}: {detail or ''}".strip())
|
||||
self.status, self.error, self.detail = status, error, detail
|
||||
|
||||
|
||||
class HubUnavailable(Exception):
|
||||
"""Хаб не отвечает — обычно не запущен (jira-hub serve)."""
|
||||
|
||||
|
||||
class HubClient:
|
||||
def __init__(self, base_url: str, token: str, timeout: float = 60):
|
||||
self.base, self.token, self.timeout = base_url.rstrip("/"), token, timeout
|
||||
|
||||
@classmethod
|
||||
def from_home(cls, timeout: float = 60) -> "HubClient":
|
||||
conn = db.connect(paths.db_path())
|
||||
try:
|
||||
settings.ensure_defaults(conn)
|
||||
return cls(f"http://127.0.0.1:{settings.get(conn, 'port')}", settings.tokens(conn)["client"], timeout)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def call(self, method: str, path: str, body=None, params=None):
|
||||
url = self.base + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")})
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", "Bearer " + self.token)
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
payload = json.loads(e.read().decode("utf-8"))
|
||||
except Exception:
|
||||
payload = {"error": "http", "detail": str(e)}
|
||||
raise HubError(e.code, payload.get("error", "http"), payload.get("detail")) from None
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
|
||||
raise HubUnavailable(f"хаб не отвечает ({self.base}): {e}") from None
|
||||
|
||||
def get(self, path, **params):
|
||||
return self.call("GET", path, params=params or None)
|
||||
|
||||
def post(self, path, body=None):
|
||||
return self.call("POST", path, body if body is not None else {})
|
||||
|
||||
def put(self, path, body):
|
||||
return self.call("PUT", path, body)
|
||||
152
src/jira_hub/commands.py
Normal file
152
src/jira_hub/commands.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md).
|
||||
Здесь валидация, работа с ключами (в том числе временными new:<n>), оценка длительности и
|
||||
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
|
||||
import re
|
||||
|
||||
from . import mirror
|
||||
|
||||
TEMP_KEY = re.compile(r"^new:\d+$")
|
||||
FIELD_PARAMS = ["summary", "description", "priority", "duedate", "planned_start", "planned_end", "epic_key", "assignee"]
|
||||
|
||||
SPEC = {
|
||||
"ensure_issue": {"required": ["temp_key", "issue_type", "summary"],
|
||||
"optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels",
|
||||
"epic_key", "assignee"],
|
||||
"keys": ["temp_key", "epic_key"]},
|
||||
"ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"],
|
||||
"keys": ["key", "epic_key"]},
|
||||
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"]},
|
||||
"ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"]},
|
||||
"ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"]},
|
||||
"ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"]},
|
||||
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]},
|
||||
}
|
||||
WRITE_TYPES = [t for t in SPEC if t != "refresh"]
|
||||
|
||||
|
||||
class CommandError(ValueError):
|
||||
"""Ошибка валидации пакета — наружу уходит как 422."""
|
||||
|
||||
|
||||
def is_temp(key) -> bool:
|
||||
return isinstance(key, str) and bool(TEMP_KEY.match(key))
|
||||
|
||||
|
||||
def referenced_keys(cmd) -> list[str]:
|
||||
out = []
|
||||
for field in SPEC[cmd["type"]]["keys"]:
|
||||
value = cmd["params"].get(field)
|
||||
if isinstance(value, list):
|
||||
out += [v for v in value if isinstance(v, str) and v]
|
||||
elif isinstance(value, str) and value:
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
def temp_keys_used(cmd) -> list[str]:
|
||||
"""Чужие временные ключи (свой temp_key у ensure_issue — не зависимость)."""
|
||||
own = cmd["params"].get("temp_key") if cmd["type"] == "ensure_issue" else None
|
||||
return [k for k in referenced_keys(cmd) if is_temp(k) and k != own]
|
||||
|
||||
|
||||
def validate(commands) -> list[dict]:
|
||||
if not isinstance(commands, list) or not commands:
|
||||
raise CommandError("пакет без команд")
|
||||
defined, out = set(), []
|
||||
for i, cmd in enumerate(commands, 1):
|
||||
if not isinstance(cmd, dict) or "type" not in cmd:
|
||||
raise CommandError(f"команда {i}: нет type")
|
||||
ctype, params = cmd["type"], cmd.get("params") or {}
|
||||
if ctype not in SPEC:
|
||||
raise CommandError(f"команда {i}: неизвестный тип «{ctype}»")
|
||||
if not isinstance(params, dict):
|
||||
raise CommandError(f"команда {i}: params не объект")
|
||||
spec = SPEC[ctype]
|
||||
missing = [f for f in spec["required"] if params.get(f) in (None, "", [])]
|
||||
if missing:
|
||||
raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}")
|
||||
unknown = set(params) - set(spec["required"]) - set(spec["optional"])
|
||||
if unknown:
|
||||
raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}")
|
||||
if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]):
|
||||
raise CommandError(f"команда {i}: ensure_fields без полей")
|
||||
if ctype == "refresh" and not (params.get("keys") or params.get("jql")):
|
||||
raise CommandError(f"команда {i}: refresh без keys и jql")
|
||||
if ctype == "ensure_issue":
|
||||
if not is_temp(params["temp_key"]):
|
||||
raise CommandError(f"команда {i}: temp_key должен быть new:<n>")
|
||||
if params["temp_key"] in defined:
|
||||
raise CommandError(f"команда {i}: temp_key {params['temp_key']} уже объявлен")
|
||||
if params["issue_type"] not in ("task", "epic"):
|
||||
raise CommandError(f"команда {i}: issue_type — task или epic")
|
||||
for key in temp_keys_used({"type": ctype, "params": params}):
|
||||
if key not in defined:
|
||||
raise CommandError(f"команда {i}: временный ключ {key} не объявлен ранее в пакете командой ensure_issue")
|
||||
if ctype == "ensure_issue":
|
||||
defined.add(params["temp_key"])
|
||||
out.append({"type": ctype, "params": params})
|
||||
return out
|
||||
|
||||
|
||||
def substitute(cmd_type, params, mapping: dict) -> dict:
|
||||
"""Подставить настоящие ключи вместо временных (temp_key самой ensure_issue не трогается)."""
|
||||
out = dict(params)
|
||||
for field in SPEC[cmd_type]["keys"]:
|
||||
if field == "temp_key":
|
||||
continue
|
||||
value = out.get(field)
|
||||
if isinstance(value, list):
|
||||
out[field] = [mapping.get(v, v) for v in value]
|
||||
elif isinstance(value, str):
|
||||
out[field] = mapping.get(value, value)
|
||||
return out
|
||||
|
||||
|
||||
def estimate(types, pacing: dict) -> int:
|
||||
return sum(int(pacing.get(t, 0)) for t in types)
|
||||
|
||||
|
||||
def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]:
|
||||
"""Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.
|
||||
reporter: логин Jira, под которым агент заводит задачи. ensure_issue подтверждается ТОЛЬКО задачей этого
|
||||
автора; пока reporter неизвестен (None), по состоянию не подтверждается (R-11): совпадение темы с чужой
|
||||
задачей не доказательство, а запись в чужую задачу недопустима."""
|
||||
ctype, p = cmd["type"], cmd["params"]
|
||||
if ctype in ("ensure_comment", "refresh"):
|
||||
return False, None
|
||||
if ctype == "ensure_issue":
|
||||
if reporter is None:
|
||||
return False, None
|
||||
row = conn.execute("SELECT key FROM issue WHERE summary = ? AND reporter = ? "
|
||||
"AND substr(created, 1, 10) >= date('now', '-7 days') ORDER BY created DESC LIMIT 1",
|
||||
(p["summary"], reporter)).fetchone()
|
||||
return (True, {"key": row["key"]}) if row else (False, None)
|
||||
issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
|
||||
if issue is None:
|
||||
return False, None
|
||||
if ctype == "ensure_fields":
|
||||
for field in FIELD_PARAMS:
|
||||
if field in p and (issue.get(field) or None) != (p[field] or None):
|
||||
return False, None
|
||||
if any(l not in issue["labels"] for l in p.get("labels_add", [])):
|
||||
return False, None
|
||||
if any(l in issue["labels"] for l in p.get("labels_remove", [])):
|
||||
return False, None
|
||||
return True, None
|
||||
if ctype == "ensure_status":
|
||||
target = p["target"]
|
||||
final = target[-1] if isinstance(target, list) else target
|
||||
return issue["status"] == final, None
|
||||
if ctype == "ensure_link":
|
||||
# связь есть И конфликтов нет: другой тип между теми же задачами или обратное направление любого типа
|
||||
jira_type = link_types.get(p["link_type"], p["link_type"])
|
||||
all_types = set(link_types.values())
|
||||
present = any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"])
|
||||
other_type = any(l["target_key"] == p["target"] and l["link_type"] in all_types - {jira_type}
|
||||
for l in issue["links_out"])
|
||||
reverse = any(l["source_key"] == p["target"] and l["link_type"] in all_types for l in issue["links_in"])
|
||||
return present and not other_type and not reverse, None
|
||||
if ctype == "ensure_sprint":
|
||||
want = None if p["sprint"] == "backlog" else p["sprint"]
|
||||
return (issue.get("sprint") or None) == want, None
|
||||
return False, None
|
||||
169
src/jira_hub/db.py
Normal file
169
src/jira_hub/db.py
Normal file
@ -0,0 +1,169 @@
|
||||
"""Соединение с SQLite и схема хаба. Схема создаётся при первом подключении; версия — в setting.schema_version."""
|
||||
import itertools
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS setting (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS issue (
|
||||
key TEXT PRIMARY KEY,
|
||||
id TEXT,
|
||||
issue_type TEXT,
|
||||
summary TEXT,
|
||||
description TEXT,
|
||||
status TEXT,
|
||||
priority TEXT,
|
||||
assignee TEXT,
|
||||
reporter TEXT,
|
||||
duedate TEXT,
|
||||
planned_start TEXT,
|
||||
planned_end TEXT,
|
||||
epic_key TEXT,
|
||||
labels TEXT NOT NULL DEFAULT '[]',
|
||||
sprint TEXT,
|
||||
created TEXT,
|
||||
jira_updated TEXT,
|
||||
fetched_at TEXT NOT NULL,
|
||||
neighbor INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS issue_link (
|
||||
source_key TEXT NOT NULL,
|
||||
target_key TEXT NOT NULL,
|
||||
link_type TEXT NOT NULL,
|
||||
PRIMARY KEY (source_key, target_key, link_type)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key);
|
||||
CREATE TABLE IF NOT EXISTS issue_note (
|
||||
key TEXT PRIMARY KEY,
|
||||
fact_status TEXT,
|
||||
work_kind TEXT,
|
||||
done_text TEXT,
|
||||
rest_text TEXT,
|
||||
owner_note TEXT,
|
||||
updated_at TEXT,
|
||||
updated_by TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS batch (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
cancelled_at TEXT,
|
||||
estimate_seconds INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS command (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER NOT NULL REFERENCES batch(id),
|
||||
seq INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
claimed_at TEXT,
|
||||
finished_at TEXT,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
jira_calls INTEGER NOT NULL DEFAULT 0,
|
||||
rate_limited INTEGER NOT NULL DEFAULT 0,
|
||||
pause_seconds INTEGER,
|
||||
depends_on INTEGER REFERENCES command(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq);
|
||||
CREATE TABLE IF NOT EXISTS sync_run (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
query TEXT,
|
||||
issues_count INTEGER NOT NULL DEFAULT 0,
|
||||
links_count INTEGER NOT NULL DEFAULT 0,
|
||||
jira_calls INTEGER NOT NULL DEFAULT 0,
|
||||
rate_limited INTEGER NOT NULL DEFAULT 0,
|
||||
command_id INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_seen_at TEXT,
|
||||
agent_version TEXT,
|
||||
settings_version INTEGER,
|
||||
current_command_id INTEGER,
|
||||
last_jira_call_at TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def now() -> str:
|
||||
"""Текущее время UTC в ISO-8601 с секундами — единый формат дат в базе."""
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def connect(path) -> sqlite3.Connection:
|
||||
"""Открыть базу (создать при отсутствии), применить схему. Соединение в автокоммите; для атомарных
|
||||
операций — transaction()."""
|
||||
is_memory = str(path) == ":memory:"
|
||||
if not is_memory:
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), timeout=10, isolation_level=None, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
if not is_memory:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
migrate(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(SCHEMA)
|
||||
row = conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()
|
||||
if row is None:
|
||||
conn.execute("INSERT INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),))
|
||||
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
||||
|
||||
|
||||
_savepoints = itertools.count(1)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction(conn: sqlite3.Connection):
|
||||
"""BEGIN IMMEDIATE … COMMIT; при исключении — ROLLBACK. Нужна там, где читаем и пишем вместе (claim, отчёт).
|
||||
Вложенный вызов (транзакция уже открыта) идёт через SAVEPOINT: при успехе RELEASE, при исключении
|
||||
ROLLBACK TO + RELEASE и исключение пробрасывается — вызывающий может его перехватить, внешняя
|
||||
транзакция при этом остаётся целой, а записи вложенного блока не сохраняются.
|
||||
Соединение не должно делиться между потоками: каждый поток/запрос открывает своё."""
|
||||
if conn.in_transaction:
|
||||
name = f"sp_{next(_savepoints)}"
|
||||
conn.execute(f"SAVEPOINT {name}")
|
||||
try:
|
||||
yield conn
|
||||
except BaseException:
|
||||
conn.execute(f"ROLLBACK TO {name}")
|
||||
conn.execute(f"RELEASE {name}")
|
||||
raise
|
||||
else:
|
||||
conn.execute(f"RELEASE {name}")
|
||||
return
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
yield conn
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
else:
|
||||
conn.execute("COMMIT")
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0)
|
||||
|
||||
|
||||
def parse(ts: str) -> datetime:
|
||||
"""ISO-8601 из базы → aware datetime (naive считаем UTC)."""
|
||||
d = datetime.fromisoformat(ts)
|
||||
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||
88
src/jira_hub/importer.py
Normal file
88
src/jira_hub/importer.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""Разовый импорт CSV-пакета admin/jira-sync: служебные колонки → issue_note; links.csv — сверочный набор
|
||||
(ожидаемые связи против зеркала), не команды. Повтор обновляет заметки, не дублирует.
|
||||
Пустая ячейка CSV не меняет существующее поле заметки (правки, сделанные в хабе, сохраняются)."""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from . import db, mirror, settings
|
||||
|
||||
|
||||
def _rows(path: Path) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def _clean(value) -> str | None:
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _note(conn, key, fields: dict) -> None:
|
||||
"""Записать только непустые поля; если их нет — заметку не трогаем."""
|
||||
fields = {k: v for k, v in fields.items() if v is not None}
|
||||
if fields:
|
||||
mirror.set_note(conn, key, fields, "import")
|
||||
|
||||
|
||||
def import_package(conn, folder) -> dict:
|
||||
folder = Path(folder)
|
||||
with db.transaction(conn):
|
||||
return _import(conn, folder)
|
||||
|
||||
|
||||
def _import(conn, folder: Path) -> dict:
|
||||
notes = 0
|
||||
csv_keys = []
|
||||
|
||||
for r in _rows(folder / "tasks.csv"):
|
||||
key = _clean(r.get("Код"))
|
||||
if not key:
|
||||
continue
|
||||
_note(conn, key, {
|
||||
"fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")),
|
||||
"done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")),
|
||||
"owner_note": _clean(r.get("Комментарий актуализации"))})
|
||||
notes += 1
|
||||
csv_keys.append(key)
|
||||
|
||||
new_keys = {} # new:<строка> → ключ Jira (как в jira_sync.py)
|
||||
for i, r in enumerate(_rows(folder / "new_tasks.csv"), 2):
|
||||
real = _clean(r.get("Ключ"))
|
||||
key = real or f"new:{i}"
|
||||
if real:
|
||||
new_keys[f"new:{i}"] = real
|
||||
csv_keys.append(real)
|
||||
_note(conn, key, {
|
||||
"fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")),
|
||||
"rest_text": _clean(r.get("Основание (внутр.)")),
|
||||
"owner_note": None if real else _clean(r.get("Тема"))})
|
||||
notes += 1
|
||||
|
||||
for r in _rows(folder / "epics.csv"):
|
||||
key = _clean(r.get("Ключ"))
|
||||
if not key:
|
||||
continue
|
||||
_note(conn, key, {"work_kind": "эпик", "owner_note": _clean(r.get("Комментарий"))})
|
||||
notes += 1
|
||||
csv_keys.append(key)
|
||||
|
||||
link_types = settings.get(conn, "link_types") or {}
|
||||
expected, missing_links = [], []
|
||||
for r in _rows(folder / "links.csv"):
|
||||
src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда")))
|
||||
dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда")))
|
||||
kind = (_clean(r.get("Тип")) or "").lower()
|
||||
if not src or not dst or src.startswith("new:") or dst.startswith("new:"):
|
||||
continue
|
||||
link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)}
|
||||
expected.append(link)
|
||||
issue = mirror.get_issue(conn, src)
|
||||
present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"])
|
||||
if not present:
|
||||
missing_links.append(link)
|
||||
|
||||
known = set(mirror.known_keys(conn))
|
||||
missing_keys = sorted({k for k in csv_keys if k not in known})
|
||||
return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links}
|
||||
242
src/jira_hub/mcp_server.py
Normal file
242
src/jira_hub/mcp_server.py
Normal file
@ -0,0 +1,242 @@
|
||||
"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md).
|
||||
Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение."""
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import time
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from .client import HubClient, HubError, HubUnavailable
|
||||
|
||||
OFFLINE = "хаб не запущен: jira-hub serve"
|
||||
POLL_SECONDS = 5
|
||||
AUTHOR = "claude"
|
||||
mcp = FastMCP("jira-hub")
|
||||
|
||||
|
||||
def _client() -> HubClient:
|
||||
return HubClient.from_home()
|
||||
|
||||
|
||||
def _hub_error(e: HubError) -> str:
|
||||
return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ")
|
||||
|
||||
|
||||
def guard(fn):
|
||||
"""Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции."""
|
||||
if inspect.iscoroutinefunction(fn):
|
||||
@functools.wraps(fn)
|
||||
async def awrapper(*args, **kwargs):
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
except HubUnavailable:
|
||||
return OFFLINE
|
||||
except HubError as e:
|
||||
return _hub_error(e)
|
||||
return awrapper
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except HubUnavailable:
|
||||
return OFFLINE
|
||||
except HubError as e:
|
||||
return _hub_error(e)
|
||||
return wrapper
|
||||
|
||||
|
||||
# --- форматирование --------------------------------------------------------------
|
||||
|
||||
def _cut(text, n=80):
|
||||
text = (text or "").replace("\n", " ")
|
||||
return text if len(text) <= n else text[: n - 1] + "…"
|
||||
|
||||
|
||||
def _v(value):
|
||||
return "—" if value in (None, "", []) else str(value)
|
||||
|
||||
|
||||
def fmt_issues(issues) -> str:
|
||||
if not issues:
|
||||
return "задач нет"
|
||||
lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"]
|
||||
for i in issues:
|
||||
lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")),
|
||||
_v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_issue(d) -> str:
|
||||
head = [f"{d['key']} — {d.get('summary')}",
|
||||
f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; "
|
||||
f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}",
|
||||
f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; "
|
||||
f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {_v(d.get('jira_updated'))}; прочитано: {_v(d.get('fetched_at'))}"]
|
||||
links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \
|
||||
[f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])]
|
||||
note = d.get("note") or {}
|
||||
notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)]
|
||||
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
|
||||
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"])
|
||||
+ ["описание:", d.get("description") or "—"])
|
||||
|
||||
|
||||
def fmt_batch(b) -> str:
|
||||
est = round((b.get("remaining_seconds") or 0) / 60)
|
||||
lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; "
|
||||
+ ", ".join(f"{k}: {v}" for k, v in (b.get("counts") or {}).items())]
|
||||
for c in b.get("commands", []):
|
||||
lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}"
|
||||
+ (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else ""))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_agent(a) -> str:
|
||||
state = "онлайн" if a.get("online") else "офлайн"
|
||||
cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна"
|
||||
out = [f"агент {state} (версия {_v(a.get('agent_version'))}, настройки v{_v(a.get('settings_version'))}, последний контакт {_v(a.get('last_seen_at'))}); {cur}; "
|
||||
f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"]
|
||||
if a.get("warning"):
|
||||
out.append("внимание: " + a["warning"])
|
||||
for s in a.get("stuck", []):
|
||||
out.append(f"нет ответа от агента: #{s['id']} {s['type']} с {s['claimed_at']}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# --- инструменты -----------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "",
|
||||
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
|
||||
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
|
||||
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
|
||||
data = _client().get("/issues", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status,
|
||||
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
|
||||
return fmt_issues(data["issues"])
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def issue(key: str) -> str:
|
||||
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание."""
|
||||
return fmt_issue(_client().get(f"/issues/{key}"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None,
|
||||
rest_text: str | None = None, owner_note: str | None = None) -> str:
|
||||
"""Правка служебных заметок задачи (только переданные поля)."""
|
||||
fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text,
|
||||
rest_text=rest_text, owner_note=owner_note).items() if v is not None}
|
||||
fields["updated_by"] = AUTHOR
|
||||
note = _client().put(f"/issues/{key}/note", fields)
|
||||
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def batch_create(title: str, commands: list[dict]) -> str:
|
||||
"""Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status|
|
||||
ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам."""
|
||||
b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
|
||||
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def batch_status(id: int) -> str:
|
||||
"""Состояние пакета и его команд."""
|
||||
return fmt_batch(_client().get(f"/batches/{id}"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
|
||||
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера."""
|
||||
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60
|
||||
client = _client()
|
||||
while True:
|
||||
b = await asyncio.to_thread(client.get, f"/batches/{id}")
|
||||
if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline:
|
||||
return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)")
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def batches(limit: int = 10) -> str:
|
||||
"""Последние пакеты со статусом."""
|
||||
items = _client().get("/batches", limit=limit)["batches"]
|
||||
return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def command_retry(id: int) -> str:
|
||||
"""Повторить команду — новый пакет с теми же параметрами."""
|
||||
b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR})
|
||||
return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def batch_cancel(id: int) -> str:
|
||||
"""Отменить пакет (только команды в очереди; взятую агентом — command_cancel с force через API)."""
|
||||
return fmt_batch(_client().post(f"/batches/{id}/cancel", {}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
|
||||
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
|
||||
wait=true — дождаться результата (до 30 мин)."""
|
||||
body = {"full": True} if full or not keys else {"keys": keys}
|
||||
body["author"] = AUTHOR
|
||||
b = await asyncio.to_thread(_client().post, "/refresh", body)
|
||||
return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def agent_status() -> str:
|
||||
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа."""
|
||||
return fmt_agent(_client().get("/agent"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def sync_log(limit: int = 10) -> str:
|
||||
"""Журнал обновлений зеркала."""
|
||||
runs = _client().get("/sync-runs", limit=limit)["runs"]
|
||||
return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, "
|
||||
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}"
|
||||
for r in runs) or "обновлений не было"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def settings_get(key: str = "") -> str:
|
||||
"""Настройки хаба (все или одна по ключу)."""
|
||||
data = _client().get("/settings")
|
||||
return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def settings_set(key: str, value: str) -> str:
|
||||
"""Записать настройку: value — JSON. Поднимает settings_version."""
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError as e:
|
||||
return f"значение не JSON: {e}"
|
||||
out = _client().put("/settings", {key: parsed})
|
||||
return f"settings_version = {out['settings_version']}"
|
||||
|
||||
|
||||
def run() -> None:
|
||||
mcp.run(transport="stdio")
|
||||
123
src/jira_hub/mirror.py
Normal file
123
src/jira_hub/mirror.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note).
|
||||
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
|
||||
import json
|
||||
|
||||
from . import db
|
||||
|
||||
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
||||
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
||||
NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"]
|
||||
|
||||
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
|
||||
"work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"}
|
||||
|
||||
|
||||
def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict:
|
||||
"""Upsert задач; связи с source_key среди присланных задач заменяются присланными.
|
||||
Связи, у которых источник не прислан, не трогаются до загрузки их источника.
|
||||
authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая
|
||||
в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по
|
||||
произвольной JQL): флаг только поднимается, никогда не снижается."""
|
||||
fetched_at = fetched_at or db.now()
|
||||
neighbors = set(neighbor_keys)
|
||||
keys = []
|
||||
with db.transaction(conn):
|
||||
for it in issues:
|
||||
row = {c: it.get(c) for c in ISSUE_COLUMNS}
|
||||
row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False)
|
||||
row["fetched_at"] = fetched_at
|
||||
row["neighbor"] = 1 if it["key"] in neighbors else 0
|
||||
cols = list(row)
|
||||
sets = [f"{c} = excluded.{c}" for c in cols if c not in ("key", "neighbor")]
|
||||
sets.append("neighbor = excluded.neighbor" if authoritative else "neighbor = MAX(issue.neighbor, excluded.neighbor)")
|
||||
conn.execute(
|
||||
f"INSERT INTO issue({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
|
||||
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets),
|
||||
[row[c] for c in cols])
|
||||
keys.append(it["key"])
|
||||
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
|
||||
for link in links:
|
||||
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
|
||||
(link["source_key"], link["target_key"], link["link_type"]))
|
||||
return {"issues": len(keys), "links": len(links)}
|
||||
|
||||
|
||||
def known_keys(conn) -> list[str]:
|
||||
return [r["key"] for r in conn.execute("SELECT key FROM issue ORDER BY key")]
|
||||
|
||||
|
||||
def _issue_dict(row) -> dict:
|
||||
d = dict(row)
|
||||
d["labels"] = json.loads(d.get("labels") or "[]")
|
||||
d["neighbor"] = bool(d.get("neighbor"))
|
||||
return d
|
||||
|
||||
|
||||
def list_issues(conn, filters=None, limit=50, offset=0) -> list[dict]:
|
||||
filters = filters or {}
|
||||
where, args = [], []
|
||||
for name, sql in _FILTERS.items():
|
||||
if filters.get(name) not in (None, ""):
|
||||
where.append(sql)
|
||||
args.append(filters[name])
|
||||
if filters.get("label"):
|
||||
where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)")
|
||||
args.append(filters["label"])
|
||||
if filters.get("neighbor") not in (None, ""):
|
||||
where.append("i.neighbor = ?")
|
||||
args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0)
|
||||
if filters.get("q"):
|
||||
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
|
||||
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
|
||||
sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
|
||||
+ (" WHERE " + " AND ".join(where) if where else "")
|
||||
+ " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?")
|
||||
return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])]
|
||||
|
||||
|
||||
def get_issue(conn, key) -> dict | None:
|
||||
row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
d = _issue_dict(row)
|
||||
d["links_out"] = [dict(r) for r in conn.execute(
|
||||
"SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))]
|
||||
d["links_in"] = [dict(r) for r in conn.execute(
|
||||
"SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))]
|
||||
d["note"] = get_note(conn, key)
|
||||
d["commands"] = [dict(r) for r in conn.execute(
|
||||
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",
|
||||
(f'%"{key}"%',))]
|
||||
return d
|
||||
|
||||
|
||||
def get_note(conn, key) -> dict | None:
|
||||
row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def set_note(conn, key, fields: dict, updated_by: str) -> dict:
|
||||
unknown = set(fields) - set(NOTE_FIELDS)
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные поля заметки: {', '.join(sorted(unknown))}")
|
||||
with db.transaction(conn): # чтение, слияние и запись — атомарно (каждый запрос и поток открывает своё соединение, R-2)
|
||||
cur = get_note(conn, key) or {"key": key}
|
||||
cur.update(fields)
|
||||
cur["updated_at"], cur["updated_by"] = db.now(), updated_by
|
||||
cols = ["key"] + NOTE_FIELDS + ["updated_at", "updated_by"]
|
||||
conn.execute(f"INSERT INTO issue_note({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))}) "
|
||||
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(f"{c} = excluded.{c}" for c in cols[1:]),
|
||||
[cur.get(c) for c in cols])
|
||||
return get_note(conn, key)
|
||||
|
||||
|
||||
def rename_note(conn, old_key, new_key) -> None:
|
||||
"""После создания задачи: заметка с временного ключа переезжает на настоящий (старая запись под новым ключом,
|
||||
если вдруг была, уступает место)."""
|
||||
if old_key == new_key:
|
||||
return
|
||||
with db.transaction(conn):
|
||||
if get_note(conn, old_key) is None:
|
||||
return
|
||||
conn.execute("DELETE FROM issue_note WHERE key = ?", (new_key,))
|
||||
conn.execute("UPDATE issue_note SET key = ? WHERE key = ?", (new_key, old_key))
|
||||
15
src/jira_hub/paths.py
Normal file
15
src/jira_hub/paths.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Где лежат данные хаба: каталог, база, резервные копии."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def home() -> Path:
|
||||
return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub")))
|
||||
|
||||
|
||||
def db_path() -> Path:
|
||||
return home() / "hub.sqlite"
|
||||
|
||||
|
||||
def backups_dir() -> Path:
|
||||
return home() / "backups"
|
||||
314
src/jira_hub/queue.py
Normal file
314
src/jira_hub/queue.py
Normal file
@ -0,0 +1,314 @@
|
||||
"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление.
|
||||
Хаб никогда не повторяет команды сам (design.md, решение 4)."""
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
|
||||
from . import commands as C
|
||||
from . import db, mirror, settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"}
|
||||
STALE_MINUTES = 10 # взятая команда старше — не блокирует выдачу, показывается как «нет ответа»
|
||||
ONLINE_MINUTES = 5
|
||||
|
||||
|
||||
class ClaimedError(Exception):
|
||||
def __init__(self, claimed_at):
|
||||
super().__init__(f"команда взята агентом {claimed_at}; отмена — с force")
|
||||
self.claimed_at = claimed_at
|
||||
|
||||
|
||||
def _cmd(row) -> dict:
|
||||
d = dict(row)
|
||||
d["params"] = json.loads(d["params"])
|
||||
d["result"] = json.loads(d["result"]) if d.get("result") else None
|
||||
return d
|
||||
|
||||
|
||||
def get_command(conn, command_id) -> dict | None:
|
||||
row = conn.execute("SELECT * FROM command WHERE id = ?", (command_id,)).fetchone()
|
||||
return _cmd(row) if row else None
|
||||
|
||||
|
||||
def _dump(value) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
# --- пакеты -----------------------------------------------------------------
|
||||
|
||||
def create_batch(conn, title, author, cmds) -> dict:
|
||||
cmds = C.validate(cmds)
|
||||
keys = []
|
||||
for c in cmds:
|
||||
keys += [k for k in C.referenced_keys(c) if k not in keys]
|
||||
if any(c["type"] != "refresh" for c in cmds) and keys:
|
||||
cmds.append({"type": "refresh", "params": {"keys": keys}})
|
||||
est = C.estimate([c["type"] for c in cmds], settings.pacing(conn))
|
||||
with db.transaction(conn):
|
||||
cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds) VALUES (?, ?, ?, ?)",
|
||||
(title, author or "api", db.now(), est))
|
||||
batch_id = cur.lastrowid
|
||||
defined = {}
|
||||
for seq, c in enumerate(cmds, 1):
|
||||
deps = [defined[k] for k in C.temp_keys_used(c) if k in defined]
|
||||
cur = conn.execute("INSERT INTO command(batch_id, seq, type, params, depends_on) VALUES (?, ?, ?, ?, ?)",
|
||||
(batch_id, seq, c["type"], _dump(c["params"]), deps[0] if deps else None))
|
||||
if c["type"] == "ensure_issue":
|
||||
defined[c["params"]["temp_key"]] = cur.lastrowid
|
||||
return {"id": batch_id, "estimate_seconds": est, "count": len(cmds)}
|
||||
|
||||
|
||||
def _status_of(cmds, cancelled_at=None) -> str:
|
||||
sts = [c["status"] for c in cmds]
|
||||
if not sts:
|
||||
return "done"
|
||||
if all(s in TERMINAL for s in sts):
|
||||
return "cancelled" if cancelled_at and all(s == "cancelled" for s in sts) else "done"
|
||||
if any(s == "claimed" for s in sts) or any(s in TERMINAL for s in sts):
|
||||
return "running"
|
||||
return "queued"
|
||||
|
||||
|
||||
def batch_view(conn, batch_id, with_commands=True) -> dict | None:
|
||||
b = conn.execute("SELECT * FROM batch WHERE id = ?", (batch_id,)).fetchone()
|
||||
if b is None:
|
||||
return None
|
||||
cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))]
|
||||
pacing = settings.pacing(conn)
|
||||
d = dict(b)
|
||||
d["status"] = _status_of(cmds, b["cancelled_at"])
|
||||
d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})}
|
||||
d["remaining_seconds"] = C.estimate([c["type"] for c in cmds if c["status"] in ("queued", "claimed")], pacing)
|
||||
if with_commands:
|
||||
d["commands"] = cmds
|
||||
return d
|
||||
|
||||
|
||||
def list_batches(conn, limit=20) -> list[dict]:
|
||||
ids = [r["id"] for r in conn.execute("SELECT id FROM batch ORDER BY id DESC LIMIT ?", (int(limit),))]
|
||||
return [batch_view(conn, i, with_commands=False) for i in ids]
|
||||
|
||||
|
||||
# --- выдача -----------------------------------------------------------------
|
||||
|
||||
def _mapping(conn, batch_id) -> dict:
|
||||
"""temp_key → настоящий ключ по завершённым ensure_issue пакета."""
|
||||
rows = conn.execute("SELECT json_extract(params, '$.temp_key') AS t, json_extract(result, '$.key') AS k FROM command "
|
||||
"WHERE batch_id = ? AND type = 'ensure_issue' AND status IN ('done', 'confirmed_by_state')",
|
||||
(batch_id,)).fetchall()
|
||||
return {r["t"]: r["k"] for r in rows if r["k"]}
|
||||
|
||||
|
||||
def _definer_status(conn, batch_id, temp_key):
|
||||
r = conn.execute("SELECT status FROM command WHERE batch_id = ? AND type = 'ensure_issue' "
|
||||
"AND json_extract(params, '$.temp_key') = ?", (batch_id, temp_key)).fetchone()
|
||||
return r["status"] if r else "failed"
|
||||
|
||||
|
||||
def _definer_stale(conn, batch_id, temp_key) -> bool:
|
||||
"""Определитель взят агентом и давно не отвечает (старше STALE_MINUTES)."""
|
||||
cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat()
|
||||
return conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type = 'ensure_issue' AND status = 'claimed' "
|
||||
"AND claimed_at <= ? AND json_extract(params, '$.temp_key') = ?",
|
||||
(batch_id, cutoff, temp_key)).fetchone() is not None
|
||||
|
||||
|
||||
def _next(conn) -> dict | None:
|
||||
rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id "
|
||||
"WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall()
|
||||
blocked_batches = set() # пакеты, ждущие зависшего определителя (R-13)
|
||||
for row in rows:
|
||||
if row["batch_id"] in blocked_batches:
|
||||
continue
|
||||
cmd = _cmd(row)
|
||||
needed = C.temp_keys_used(cmd)
|
||||
if needed:
|
||||
mapping = _mapping(conn, cmd["batch_id"])
|
||||
unresolved = [k for k in needed if k not in mapping]
|
||||
pending = [k for k in unresolved if _definer_status(conn, cmd["batch_id"], k) not in TERMINAL]
|
||||
if pending:
|
||||
if all(_definer_stale(conn, cmd["batch_id"], k) for k in pending):
|
||||
blocked_batches.add(cmd["batch_id"]) # определитель завис: остаток пакета ждёт человека,
|
||||
continue # очередь идёт к следующему пакету
|
||||
return None # создание ещё не завершено — ждём (одна в полёте)
|
||||
cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping)
|
||||
if cmd["type"] == "refresh":
|
||||
cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)]
|
||||
unresolved = [] if cmd["params"]["keys"] else unresolved
|
||||
if unresolved:
|
||||
conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?",
|
||||
(db.now(), f"задача {unresolved[0]} не создана", cmd["id"]))
|
||||
continue
|
||||
return cmd
|
||||
return None
|
||||
|
||||
|
||||
def _fresh_claimed_exists(conn) -> bool:
|
||||
cutoff = (db.utcnow() - timedelta(minutes=STALE_MINUTES)).isoformat()
|
||||
return conn.execute("SELECT 1 FROM command WHERE status = 'claimed' AND claimed_at > ? LIMIT 1", (cutoff,)).fetchone() is not None
|
||||
|
||||
|
||||
def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None:
|
||||
now = db.now()
|
||||
to_backup = None
|
||||
with db.transaction(conn):
|
||||
conn.execute("UPDATE agent_status SET last_seen_at = ?, agent_version = COALESCE(?, agent_version), "
|
||||
"settings_version = COALESCE(?, settings_version), last_jira_call_at = COALESCE(?, last_jira_call_at) WHERE id = 1",
|
||||
(now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at))
|
||||
if _fresh_claimed_exists(conn):
|
||||
return None
|
||||
cmd = _next(conn)
|
||||
if cmd is None:
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1")
|
||||
return None
|
||||
pause = int(settings.pacing(conn).get(cmd["type"], 0))
|
||||
first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'",
|
||||
(cmd["batch_id"],)).fetchone()["n"] == 0
|
||||
has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type != 'refresh' LIMIT 1",
|
||||
(cmd["batch_id"],)).fetchone() is not None
|
||||
conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ? WHERE id = ?",
|
||||
(now, pause, _dump(cmd["params"]), cmd["id"]))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],))
|
||||
if cmd["type"] == "refresh":
|
||||
p = cmd["params"]
|
||||
conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)",
|
||||
(now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"]))
|
||||
if first and has_writes:
|
||||
to_backup = f"batch-{cmd['batch_id']}"
|
||||
if to_backup and backup:
|
||||
try:
|
||||
backup(to_backup)
|
||||
except Exception: # сбой копии не должен держать очередь: команда уже взята
|
||||
log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True)
|
||||
return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]}
|
||||
|
||||
|
||||
# --- отчёты -----------------------------------------------------------------
|
||||
|
||||
def is_full_refresh(conn, command_id) -> bool:
|
||||
"""Команда — refresh по базовой JQL (полный прогон): только он вправе сбрасывать флаг neighbor."""
|
||||
if not command_id:
|
||||
return False
|
||||
cmd = get_command(conn, command_id)
|
||||
return bool(cmd and cmd["type"] == "refresh" and cmd["params"].get("jql")
|
||||
and cmd["params"]["jql"] == settings.get(conn, "base_jql"))
|
||||
|
||||
|
||||
def record_state(conn, command_id, issues_count, links_count) -> None:
|
||||
conn.execute("UPDATE sync_run SET issues_count = issues_count + ?, links_count = links_count + ? WHERE command_id = ?",
|
||||
(int(issues_count), int(links_count), command_id))
|
||||
|
||||
|
||||
def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict:
|
||||
if status not in ("done", "failed"):
|
||||
raise ValueError("status — done или failed")
|
||||
with db.transaction(conn):
|
||||
cmd = get_command(conn, command_id)
|
||||
if cmd is None:
|
||||
raise KeyError(command_id)
|
||||
if cmd["status"] in TERMINAL:
|
||||
return cmd # идемпотентно; cancelled не затирается
|
||||
now = db.now()
|
||||
conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?",
|
||||
(status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?",
|
||||
(now, command_id))
|
||||
if cmd["type"] == "ensure_issue" and status == "done" and result and result.get("key"):
|
||||
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
|
||||
if cmd["type"] == "refresh":
|
||||
conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?",
|
||||
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||
if status == "done" and is_full_refresh(conn, command_id):
|
||||
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед
|
||||
run = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (command_id,)).fetchone()
|
||||
if run:
|
||||
conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],))
|
||||
if cmd["type"] == "refresh" and status == "done":
|
||||
reconcile(conn)
|
||||
return get_command(conn, command_id)
|
||||
|
||||
|
||||
def reconcile(conn) -> list[int]:
|
||||
"""Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state."""
|
||||
link_types = settings.get(conn, "link_types") or {}
|
||||
reporter = settings.get(conn, "reporter") # задел: сейчас не задаётся, ensure_issue по состоянию не подтверждается (R-11);
|
||||
# позже сюда придёт jira_user от агента
|
||||
changed = []
|
||||
for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall():
|
||||
cmd = _cmd(row)
|
||||
ok, result = C.check_confirmed(conn, cmd, link_types, reporter)
|
||||
if not ok:
|
||||
continue
|
||||
with db.transaction(conn):
|
||||
cur = conn.execute("UPDATE command SET status = 'confirmed_by_state', finished_at = ?, result = ? "
|
||||
"WHERE id = ? AND status = 'claimed'",
|
||||
(db.now(), _dump(result) if result else None, cmd["id"]))
|
||||
if cur.rowcount == 0: # отчёт агента или отмена опередили сверку
|
||||
continue
|
||||
if cmd["type"] == "ensure_issue" and result:
|
||||
mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"])
|
||||
changed.append(cmd["id"])
|
||||
return changed
|
||||
|
||||
|
||||
# --- отмена и повтор ----------------------------------------------------------
|
||||
|
||||
def cancel_batch(conn, batch_id) -> dict:
|
||||
with db.transaction(conn):
|
||||
if conn.execute("SELECT 1 FROM batch WHERE id = ?", (batch_id,)).fetchone() is None:
|
||||
raise KeyError(batch_id)
|
||||
now = db.now()
|
||||
conn.execute("UPDATE batch SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL", (now, batch_id))
|
||||
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE batch_id = ? AND status = 'queued'", (now, batch_id))
|
||||
return batch_view(conn, batch_id)
|
||||
|
||||
|
||||
def cancel_command(conn, command_id, force=False) -> dict:
|
||||
with db.transaction(conn):
|
||||
cmd = get_command(conn, command_id)
|
||||
if cmd is None:
|
||||
raise KeyError(command_id)
|
||||
if cmd["status"] == "claimed" and not force:
|
||||
raise ClaimedError(cmd["claimed_at"])
|
||||
if cmd["status"] in ("queued", "claimed"):
|
||||
conn.execute("UPDATE command SET status = 'cancelled', finished_at = ? WHERE id = ?", (db.now(), command_id))
|
||||
conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (command_id,))
|
||||
return get_command(conn, command_id)
|
||||
|
||||
|
||||
def retry_command(conn, command_id, author) -> dict:
|
||||
cmd = get_command(conn, command_id)
|
||||
if cmd is None:
|
||||
raise KeyError(command_id)
|
||||
return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}])
|
||||
|
||||
|
||||
# --- агент и автообновление ----------------------------------------------------
|
||||
|
||||
def agent_view(conn) -> dict:
|
||||
d = dict(conn.execute("SELECT * FROM agent_status WHERE id = 1").fetchone())
|
||||
now = db.utcnow()
|
||||
d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES)
|
||||
min_version = int(settings.get(conn, "min_agent_version") or 0)
|
||||
d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None
|
||||
d["rate_limited_24h"] = conn.execute("SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ?",
|
||||
((now - timedelta(days=1)).isoformat(),)).fetchone()["n"]
|
||||
cutoff = (now - timedelta(minutes=STALE_MINUTES)).isoformat()
|
||||
d["stuck"] = [dict(r) for r in conn.execute(
|
||||
"SELECT id, type, claimed_at FROM command WHERE status = 'claimed' AND claimed_at <= ? ORDER BY id", (cutoff,))]
|
||||
return d
|
||||
|
||||
|
||||
def maybe_auto_refresh(conn) -> dict | None:
|
||||
minutes = int(settings.get(conn, "auto_refresh_minutes") or 0)
|
||||
if minutes <= 0 or not agent_view(conn)["online"]:
|
||||
return None
|
||||
if conn.execute("SELECT 1 FROM command WHERE status = 'queued' LIMIT 1").fetchone() or _fresh_claimed_exists(conn):
|
||||
return None
|
||||
last = conn.execute("SELECT MAX(started_at) AS t FROM sync_run").fetchone()["t"]
|
||||
if last and db.utcnow() - db.parse(last) < timedelta(minutes=minutes):
|
||||
return None
|
||||
return create_batch(conn, "Автообновление", "hub", [{"type": "refresh", "params": {"jql": settings.get(conn, "base_jql")}}])
|
||||
159
src/jira_hub/settings.py
Normal file
159
src/jira_hub/settings.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version,
|
||||
агент перечитывает настройки, когда видит новую версию в ответе claim."""
|
||||
import json
|
||||
import secrets
|
||||
|
||||
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
|
||||
"ensure_link": 30, "ensure_sprint": 30, "refresh": 2}
|
||||
|
||||
DEFAULTS = {
|
||||
"base_jql": "project = AURORA AND labels = AWPCA",
|
||||
"project_key": "AURORA",
|
||||
"pacing": DEFAULT_PACING,
|
||||
"backup_keep": 30,
|
||||
"auto_refresh_minutes": 60,
|
||||
"min_agent_version": 1,
|
||||
"port": 8790,
|
||||
"settings_version": 1,
|
||||
"initialized": False,
|
||||
"issue_types": {},
|
||||
"epic_name_field_id": None,
|
||||
"field_ids": {},
|
||||
"epic_link": {},
|
||||
"priority_map": {},
|
||||
"priority_value_key": "name",
|
||||
"labels": [],
|
||||
"status_labels": {},
|
||||
"kind_labels": {},
|
||||
"comment_marker": "",
|
||||
"transitions_map": {},
|
||||
"link_types": {},
|
||||
"board_id": None,
|
||||
}
|
||||
|
||||
# Что получает агент (без токенов и служебных ключей хаба)
|
||||
AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",
|
||||
"priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker",
|
||||
"transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"]
|
||||
|
||||
PROTECTED = {"settings_version", "schema_version", "tokens"}
|
||||
|
||||
|
||||
def _is_int(v) -> bool:
|
||||
return isinstance(v, int) and not isinstance(v, bool)
|
||||
|
||||
|
||||
def validate(key, value) -> None:
|
||||
"""Проверить значение настройки до записи; ValueError — на 422 в API.
|
||||
pacing — объект {тип команды: целое >= 0} только с известными типами команд;
|
||||
backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535;
|
||||
остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
|
||||
неизвестные ключи и ключи с умолчанием None принимаются как есть."""
|
||||
if key == "pacing":
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("pacing — объект {тип команды: пауза в секундах}")
|
||||
for ctype, pause in value.items():
|
||||
if ctype not in DEFAULT_PACING:
|
||||
raise ValueError(f"pacing: неизвестный тип команды «{ctype}»")
|
||||
if not _is_int(pause) or pause < 0:
|
||||
raise ValueError(f"pacing.{ctype}: ожидается целое >= 0")
|
||||
return
|
||||
if key == "port":
|
||||
if not _is_int(value) or not 1 <= value <= 65535:
|
||||
raise ValueError("port — целое 1..65535")
|
||||
return
|
||||
if key in ("backup_keep", "auto_refresh_minutes", "min_agent_version"):
|
||||
if not _is_int(value) or value < 0:
|
||||
raise ValueError(f"{key} — целое >= 0")
|
||||
return
|
||||
default = DEFAULTS.get(key)
|
||||
if default is not None and type(value) is not type(default):
|
||||
names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"}
|
||||
raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}")
|
||||
|
||||
|
||||
def ensure_defaults(conn) -> None:
|
||||
for key, value in DEFAULTS.items():
|
||||
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value)))
|
||||
if get(conn, "tokens") is None:
|
||||
conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)",
|
||||
(_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),))
|
||||
|
||||
|
||||
def _dump(value) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def get(conn, key, default=None):
|
||||
row = conn.execute("SELECT value FROM setting WHERE key = ?", (key,)).fetchone()
|
||||
return default if row is None else json.loads(row["value"])
|
||||
|
||||
|
||||
def get_all(conn, hide_tokens=True) -> dict:
|
||||
out = {r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM setting ORDER BY key")}
|
||||
if hide_tokens:
|
||||
out.pop("tokens", None)
|
||||
return out
|
||||
|
||||
|
||||
def set_(conn, key, value, bump=True) -> int:
|
||||
"""Записать настройку; bump=True поднимает settings_version. Защищённые ключи снаружи не меняются."""
|
||||
if key in PROTECTED:
|
||||
raise ValueError(f"настройка «{key}» не редактируется")
|
||||
conn.execute("INSERT INTO setting(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, _dump(value)))
|
||||
if bump:
|
||||
version = get(conn, "settings_version", 1) + 1
|
||||
conn.execute("UPDATE setting SET value = ? WHERE key = 'settings_version'", (_dump(version),))
|
||||
return version
|
||||
return get(conn, "settings_version", 1)
|
||||
|
||||
|
||||
def for_agent(conn) -> dict:
|
||||
return {key: get(conn, key) for key in AGENT_KEYS}
|
||||
|
||||
|
||||
def tokens(conn) -> dict:
|
||||
return get(conn, "tokens")
|
||||
|
||||
|
||||
def pacing(conn) -> dict:
|
||||
return {**DEFAULT_PACING, **(get(conn, "pacing") or {})}
|
||||
|
||||
|
||||
def pacing_from_config(p: dict) -> dict:
|
||||
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
|
||||
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
|
||||
return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment,
|
||||
"ensure_link": comment, "ensure_sprint": comment, "refresh": 2}
|
||||
|
||||
|
||||
def init_from_config(conn, cfg: dict, force=False) -> list[str]:
|
||||
"""Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira
|
||||
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают."""
|
||||
if get(conn, "initialized") and not force:
|
||||
raise RuntimeError("хаб уже инициализирован; повторный init — с --force")
|
||||
project = cfg["project_key"]
|
||||
values = {
|
||||
"project_key": project,
|
||||
"base_jql": f"project = {project} AND labels = AWPCA",
|
||||
"issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")},
|
||||
"epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}},
|
||||
"epic_name_field_id": cfg.get("epic_name_field_id"),
|
||||
"field_ids": cfg.get("fields", {}),
|
||||
"epic_link": cfg.get("epic_link", {}),
|
||||
"priority_map": cfg.get("priority_map", {}),
|
||||
"priority_value_key": cfg.get("priority_value_key", "name"),
|
||||
"labels": cfg.get("labels", []),
|
||||
"status_labels": cfg.get("status_labels", {}),
|
||||
"kind_labels": cfg.get("kind_labels", {}),
|
||||
"comment_marker": cfg.get("comment_marker", ""),
|
||||
"transitions_map": cfg.get("transitions", {}).get("map", {}),
|
||||
"link_types": cfg.get("link_types", {}),
|
||||
"board_id": cfg.get("sprints", {}).get("board_id"),
|
||||
"pacing": pacing_from_config(cfg.get("pacing", {})),
|
||||
}
|
||||
for key, value in values.items():
|
||||
set_(conn, key, value, bump=False)
|
||||
set_(conn, "initialized", True)
|
||||
return sorted(values)
|
||||
136
tests/fixtures/config.example.json
vendored
Normal file
136
tests/fixtures/config.example.json
vendored
Normal file
@ -0,0 +1,136 @@
|
||||
{
|
||||
"base_url": "https://sdlc.go.rshbank.ru/jira",
|
||||
"auth": {
|
||||
"type": "basic",
|
||||
"user": "ваш_логин",
|
||||
"password": "ваш_пароль",
|
||||
"token": "",
|
||||
"token_env": "JIRA_TOKEN",
|
||||
"user_env": "JIRA_USER",
|
||||
"password_env": "JIRA_PASSWORD"
|
||||
},
|
||||
"verify_ssl": true,
|
||||
"ca_bundle": null,
|
||||
"timeout": 60,
|
||||
"project_key": "AURORA",
|
||||
"issue_type_task": "Задача",
|
||||
"issue_type_task_id": "10100",
|
||||
"issue_type_epic": "Фича",
|
||||
"issue_type_epic_id": "12300",
|
||||
"epic_name_field_id": "customfield_15304",
|
||||
"fields": {
|
||||
"description": "description",
|
||||
"priority": "priority",
|
||||
"duedate": "duedate",
|
||||
"labels": "labels",
|
||||
"planned_start": "customfield_10801",
|
||||
"planned_end": "customfield_10802"
|
||||
},
|
||||
"priority_value_key": "name",
|
||||
"extra_fields_task": {},
|
||||
"extra_fields_epic": {},
|
||||
"epic_link": {
|
||||
"mode": "field",
|
||||
"field_id": "customfield_15302"
|
||||
},
|
||||
"priority_map": {
|
||||
"Наивысший": "Критичный",
|
||||
"Высокий": "Высокий",
|
||||
"Средний": "Средний",
|
||||
"Низкий": "Низкий",
|
||||
"Самый низкий": "Низкий"
|
||||
},
|
||||
"labels": [
|
||||
"AWPCA",
|
||||
"actualization-2026-10-10"
|
||||
],
|
||||
"status_labels": {
|
||||
"Готово — закрыть": "fact-done",
|
||||
"Частично — переформулировать": "fact-partial",
|
||||
"В работе": "fact-in-progress",
|
||||
"Ожидает решения владельца/заказчика": "fact-decision",
|
||||
"К актуализации аналитики": "fact-docs",
|
||||
"Ожидание разработки": "fact-open",
|
||||
"План развития (вне этапа)": "fact-roadmap",
|
||||
"Снято": "fact-cancelled"
|
||||
},
|
||||
"kind_labels": {
|
||||
"аналитика": "kind-analytics",
|
||||
"решение": "kind-decision",
|
||||
"актуализация": "kind-docs",
|
||||
"разработка": "kind-dev",
|
||||
"UI": "kind-ui",
|
||||
"тестирование": "kind-test",
|
||||
"организационная": "kind-org"
|
||||
},
|
||||
"update_description": true,
|
||||
"comment_marker": "Актуализация 10.10.2026",
|
||||
"pacing": {
|
||||
"issue_seconds": 120,
|
||||
"comment_seconds": 30
|
||||
},
|
||||
"transitions": {
|
||||
"enabled": true,
|
||||
"map": {
|
||||
"Готово — закрыть": {
|
||||
"default": [
|
||||
"Разработка",
|
||||
"Готово к тестированию"
|
||||
],
|
||||
"аналитика": [
|
||||
"Разработка",
|
||||
"Готово"
|
||||
],
|
||||
"решение": [
|
||||
"Разработка",
|
||||
"Готово"
|
||||
],
|
||||
"актуализация": [
|
||||
"Разработка",
|
||||
"Готово"
|
||||
],
|
||||
"организационная": [
|
||||
"Разработка",
|
||||
"Готово"
|
||||
]
|
||||
},
|
||||
"В работе": {
|
||||
"default": "Разработка",
|
||||
"аналитика": [
|
||||
"Ожидает анализа",
|
||||
"Анализ"
|
||||
],
|
||||
"решение": [
|
||||
"Ожидает анализа",
|
||||
"Анализ"
|
||||
],
|
||||
"актуализация": [
|
||||
"Ожидает анализа",
|
||||
"Анализ"
|
||||
]
|
||||
},
|
||||
"Ожидает решения владельца/заказчика": "Ожидает анализа",
|
||||
"К актуализации аналитики": "Ожидает анализа",
|
||||
"Ожидание разработки": {
|
||||
"аналитика": "Ожидает анализа",
|
||||
"решение": "Ожидает анализа",
|
||||
"актуализация": "Ожидает анализа"
|
||||
},
|
||||
"Частично — переформулировать": {
|
||||
"аналитика": "Ожидает анализа",
|
||||
"решение": "Ожидает анализа",
|
||||
"актуализация": "Ожидает анализа"
|
||||
},
|
||||
"Снято": "Снято"
|
||||
}
|
||||
},
|
||||
"link_types": {
|
||||
"блокирует начало": "ASUPR_Блокер_Начала",
|
||||
"блокирует завершение": "ASUPR_Блокер_Завершения",
|
||||
"связанные": "ASUPR_Связанные"
|
||||
},
|
||||
"sprints": {
|
||||
"board_id": 4216,
|
||||
"_comment": "id scrum-доски проекта для команды sprints (SCRUM_AWPCA); null — единственная доска проекта, при нескольких скрипт просит указать"
|
||||
}
|
||||
}
|
||||
303
tests/test_api.py
Normal file
303
tests/test_api.py
Normal file
@ -0,0 +1,303 @@
|
||||
import json
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub import api, db, settings
|
||||
from jira_hub.client import HubClient, HubError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path):
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
h.claim_wait = 0
|
||||
conn = h.conn()
|
||||
settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"})
|
||||
tok = settings.tokens(conn)
|
||||
conn.close()
|
||||
srv = api.make_server(h, "127.0.0.1", 0)
|
||||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
th.start()
|
||||
base = f"http://127.0.0.1:{srv.server_port}"
|
||||
yield {"hub": h, "agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"]), "base": base}
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
th.join(5)
|
||||
|
||||
|
||||
def test_auth_scopes(hub):
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["agent"].get("/issues")
|
||||
assert e.value.status == 401
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/agent/claim", {})
|
||||
assert e.value.status == 401
|
||||
with pytest.raises(HubError) as e:
|
||||
HubClient(hub["base"], "wrong").get("/issues")
|
||||
assert e.value.status == 401
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].get("/nope")
|
||||
assert e.value.status == 404
|
||||
|
||||
|
||||
def test_agent_flow(hub):
|
||||
c = hub["client"]
|
||||
b = c.post("/batches", {"title": "срок", "author": "claude",
|
||||
"commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
|
||||
assert b["count"] == 2
|
||||
a = hub["agent"]
|
||||
claim = a.post("/agent/claim", {"agent_version": 1, "settings_version": 1})
|
||||
cmd = claim["command"]
|
||||
assert cmd["type"] == "ensure_fields" and cmd["pause_seconds"] == 120 and claim["settings_version"] >= 1
|
||||
assert a.get(f"/agent/commands/{cmd['id']}")["id"] == cmd["id"]
|
||||
assert a.get("/agent/settings")["link_types"]["связанные"] == "ASUPR_Связанные"
|
||||
assert a.post("/agent/claim", {})["command"] is None # одна в полёте, долгий запрос выключен
|
||||
r = a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"changed": ["duedate"]}, "jira_calls": 2})
|
||||
assert r["status"] == "done"
|
||||
assert a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "x"})["status"] == "done"
|
||||
tail = a.post("/agent/claim", {})["command"]
|
||||
assert tail["type"] == "refresh"
|
||||
out = a.post("/agent/state", {"command_id": tail["id"], "page": 1, "last_page": True,
|
||||
"issues": [{"key": "A-1", "summary": "Т", "duedate": "2026-10-23", "labels": ["AWPCA"],
|
||||
"created": "2026-10-10T10:00:00.000+0300"},
|
||||
{"key": "B-1", "summary": "Сосед", "labels": []}],
|
||||
"links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}],
|
||||
"neighbor_keys": ["B-1"]})
|
||||
assert out == {"issues": 2, "links": 1}
|
||||
assert a.get("/agent/known-keys")["keys"] == ["A-1", "B-1"]
|
||||
a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1})
|
||||
assert c.get("/batches/%d" % b["id"])["status"] == "done"
|
||||
issue = c.get("/issues/A-1")
|
||||
assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}]
|
||||
assert c.get("/issues/B-1")["neighbor"] is True
|
||||
runs = c.get("/sync-runs", limit=5)["runs"]
|
||||
assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"]
|
||||
assert c.get("/agent")["online"] is True
|
||||
|
||||
|
||||
def test_interface_routes(hub):
|
||||
c = hub["client"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.get("/issues/A-404")
|
||||
assert e.value.status == 404
|
||||
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []},
|
||||
{"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []})
|
||||
assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"]
|
||||
note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"})
|
||||
assert note["fact_status"] == "В работе" and note["updated_by"] == "owner"
|
||||
with pytest.raises(HubError) as e:
|
||||
c.put("/issues/A-1/note", {"summary": "x"})
|
||||
assert e.value.status == 422
|
||||
with pytest.raises(HubError) as e:
|
||||
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1"}}]})
|
||||
assert e.value.status == 422 and "без полей" in e.value.detail
|
||||
b = c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}},
|
||||
{"type": "ensure_fields", "params": {"key": "A-2", "duedate": "2026-10-23"}}]})
|
||||
assert c.get("/batches", limit=5)["batches"][0]["id"] == b["id"]
|
||||
cmd = hub["agent"].post("/agent/claim", {})["command"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.post(f"/commands/{cmd['id']}/cancel", {})
|
||||
assert e.value.status == 409 and e.value.detail
|
||||
assert c.post(f"/commands/{cmd['id']}/cancel", {"force": True})["status"] == "cancelled"
|
||||
assert c.post(f"/batches/{b['id']}/cancel", {})["status"] == "cancelled"
|
||||
retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"})
|
||||
assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}"
|
||||
full = c.post("/refresh", {"full": True})
|
||||
assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == "project = AURORA AND labels = AWPCA"
|
||||
keys = c.post("/refresh", {"keys": ["A-1"]})
|
||||
assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]}
|
||||
|
||||
|
||||
def test_settings_put_rejects_protected(hub):
|
||||
c = hub["client"]
|
||||
v0 = c.get("/settings")["settings_version"]
|
||||
for key in ("tokens", "settings_version"):
|
||||
with pytest.raises(HubError) as e:
|
||||
c.put("/settings", {key: "x", "pacing": {"refresh": 1}})
|
||||
assert e.value.status == 422
|
||||
assert c.get("/settings")["settings_version"] == v0
|
||||
assert "tokens" not in c.get("/settings")
|
||||
v1 = c.put("/settings", {"pacing": {"refresh": 1}})["settings_version"]
|
||||
assert v1 == v0 + 1 and c.get("/settings")["pacing"]["refresh"] == 1
|
||||
|
||||
|
||||
def test_backup_created_on_first_write(hub, tmp_path):
|
||||
c = hub["client"]
|
||||
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})
|
||||
hub["agent"].post("/agent/claim", {})
|
||||
assert len(list((tmp_path / "backups").glob("*-batch-*.sqlite"))) == 1
|
||||
|
||||
|
||||
def test_client_unavailable():
|
||||
from jira_hub.client import HubUnavailable
|
||||
with pytest.raises(HubUnavailable):
|
||||
HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues")
|
||||
|
||||
|
||||
def test_no_backup_for_refresh_batch(hub, tmp_path):
|
||||
hub["client"].post("/refresh", {"keys": ["A-1"]})
|
||||
assert hub["agent"].post("/agent/claim", {})["command"]["type"] == "refresh"
|
||||
assert not list((tmp_path / "backups").glob("*.sqlite"))
|
||||
|
||||
|
||||
def test_bad_requests(hub):
|
||||
c = hub["client"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.get("/issues", limit="abc")
|
||||
assert e.value.status == 400
|
||||
import urllib.request, urllib.error
|
||||
req = urllib.request.Request(hub["base"] + "/batches", data=b"{not json", method="POST",
|
||||
headers={"Authorization": "Bearer " + c.token})
|
||||
with pytest.raises(urllib.error.HTTPError) as e2:
|
||||
urllib.request.urlopen(req)
|
||||
assert e2.value.code == 400
|
||||
e2.value.close()
|
||||
|
||||
|
||||
# --- ревью Task 6 --------------------------------------------------------------
|
||||
|
||||
def _raw(hub, headers, path="/batches", method="POST"):
|
||||
import http.client
|
||||
host, port = hub["base"].removeprefix("http://").split(":")
|
||||
c = http.client.HTTPConnection(host, int(port), timeout=10)
|
||||
try:
|
||||
c.putrequest(method, path)
|
||||
c.putheader("Authorization", "Bearer " + hub["client"].token)
|
||||
for k, v in headers.items():
|
||||
c.putheader(k, v)
|
||||
c.endheaders()
|
||||
r = c.getresponse()
|
||||
return r.status, json.loads(r.read().decode("utf-8"))
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,status,error", [("-5", 400, "bad_content_length"), ("abc", 400, "bad_content_length"),
|
||||
(str(api.MAX_BODY + 1), 413, "too_large")])
|
||||
def test_content_length_checked(hub, value, status, error):
|
||||
got, payload = _raw(hub, {"Content-Length": value})
|
||||
assert got == status and payload["error"] == error
|
||||
|
||||
|
||||
def test_numeric_param_400_internal_bug_500(hub, monkeypatch):
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].get("/batches", limit="x")
|
||||
assert e.value.status == 400
|
||||
|
||||
def boom(*a):
|
||||
raise TypeError("внутренний баг")
|
||||
monkeypatch.setattr(api, "_COMPILED", [("GET", api.re.compile(r"^/issues$"), boom, "client")])
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].get("/issues")
|
||||
assert e.value.status == 500 and e.value.error == "internal"
|
||||
with pytest.raises(HubError) as e: # сервер жив
|
||||
hub["client"].get("/issues")
|
||||
assert e.value.status == 500
|
||||
|
||||
|
||||
def test_tick_survives_connection_failure(hub, monkeypatch):
|
||||
def broken():
|
||||
raise OSError("диск недоступен")
|
||||
monkeypatch.setattr(hub["hub"], "conn", broken)
|
||||
hub["hub"].tick() # не бросает
|
||||
monkeypatch.undo()
|
||||
hub["hub"].tick()
|
||||
|
||||
|
||||
def test_claim_long_poll_returns_new_command(hub):
|
||||
import time
|
||||
hub["hub"].claim_wait = 4
|
||||
threading.Timer(0.5, lambda: hub["client"].post("/batches", {"title": "x", "commands": [
|
||||
{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]})).start()
|
||||
t0 = time.monotonic()
|
||||
cmd = hub["agent"].post("/agent/claim", {})["command"]
|
||||
assert cmd and cmd["type"] == "ensure_fields" and time.monotonic() - t0 < 4
|
||||
|
||||
|
||||
def test_claim_long_poll_times_out_with_null(hub):
|
||||
import time
|
||||
hub["hub"].claim_wait = 1.5
|
||||
t0 = time.monotonic()
|
||||
assert hub["agent"].post("/agent/claim", {})["command"] is None
|
||||
assert 1.0 <= time.monotonic() - t0 < 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,body", [
|
||||
("/refresh", {"keys": 5}), ("/refresh", {"keys": "A-1"}), ("/refresh", {"keys": [1]}), ("/refresh", {"jql": 5}),
|
||||
("/batches", {"title": "x", "commands": 5}), ("/batches", {"title": "x", "commands": "x"}),
|
||||
("/batches", {"title": ["x"], "commands": []}), ("/batches", {"title": "x", "author": 5, "commands": []}),
|
||||
])
|
||||
def test_bad_body_shapes_are_422(hub, path, body):
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post(path, body)
|
||||
assert e.value.status == 422
|
||||
|
||||
|
||||
def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub):
|
||||
c = hub["client"]
|
||||
v0 = c.get("/settings")["settings_version"]
|
||||
with pytest.raises(HubError) as e:
|
||||
c.put("/settings", {"pacing": {"refresh": 3}, "tokens": {}})
|
||||
assert 400 <= e.value.status < 500
|
||||
assert c.get("/settings")["settings_version"] == v0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [
|
||||
{"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}},
|
||||
{"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1},
|
||||
{"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"base_jql": 5},
|
||||
{"labels": {}}, {"initialized": 1},
|
||||
{"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается
|
||||
])
|
||||
def test_settings_put_invalid_value_is_422(hub, body):
|
||||
c = hub["client"]
|
||||
before = c.get("/settings")
|
||||
with pytest.raises(HubError) as e:
|
||||
c.put("/settings", body)
|
||||
assert e.value.status == 422
|
||||
assert c.get("/settings") == before
|
||||
|
||||
|
||||
def test_settings_put_valid_values_pass(hub):
|
||||
c = hub["client"]
|
||||
v0 = c.get("/settings")["settings_version"]
|
||||
out = c.put("/settings", {"pacing": {"refresh": 0, "ensure_fields": 90}, "backup_keep": 0, "port": 8800,
|
||||
"auto_refresh_minutes": 15, "min_agent_version": 3, "link_types": {"x": "Y"},
|
||||
"labels": ["AWPCA"], "board_id": 12})
|
||||
assert out["settings_version"] == v0 + 8
|
||||
got = c.get("/settings")
|
||||
assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800 and got["board_id"] == 12
|
||||
|
||||
|
||||
def _run_refresh(hub, body, issues, neighbor_keys=()):
|
||||
"""Поставить refresh, взять его агентом, загрузить состояние, отчитаться done."""
|
||||
hub["client"].post("/refresh", body)
|
||||
cmd = hub["agent"].post("/agent/claim", {})["command"]
|
||||
assert cmd["type"] == "refresh"
|
||||
hub["agent"].post("/agent/state", {"command_id": cmd["id"], "issues": issues, "links": [],
|
||||
"neighbor_keys": list(neighbor_keys)})
|
||||
hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done"})
|
||||
|
||||
|
||||
def test_refresh_by_keys_keeps_neighbor_flag(hub):
|
||||
"""I-2(b): точечное обновление соседней задачи не опускает флаг."""
|
||||
_run_refresh(hub, {"full": True}, [{"key": "A-1", "summary": "т", "labels": []},
|
||||
{"key": "B-1", "summary": "сосед", "labels": []}], neighbor_keys=["B-1"])
|
||||
assert hub["client"].get("/issues/B-1")["neighbor"] is True
|
||||
_run_refresh(hub, {"keys": ["B-1"]}, [{"key": "B-1", "summary": "сосед 2", "labels": []}])
|
||||
issue = hub["client"].get("/issues/B-1")
|
||||
assert issue["summary"] == "сосед 2" and issue["neighbor"] is True
|
||||
|
||||
|
||||
def test_full_refresh_marks_unseen_issue_as_neighbor(hub):
|
||||
"""I-2(a): полный прогон по базовой JQL: не обновлённая им задача получает neighbor=1."""
|
||||
conn = hub["hub"].conn()
|
||||
conn.execute("INSERT INTO issue(key, summary, labels, fetched_at) VALUES ('X-1', 'вышла из выборки', '[]', '2020-01-01T00:00:00+00:00')")
|
||||
conn.close()
|
||||
_run_refresh(hub, {"full": True}, [{"key": "A-1", "summary": "т", "labels": []}])
|
||||
assert hub["client"].get("/issues/X-1")["neighbor"] is True
|
||||
assert hub["client"].get("/issues/A-1")["neighbor"] is False
|
||||
# частичный refresh (по ключам) других задач ничего не помечает
|
||||
_run_refresh(hub, {"keys": ["A-1"]}, [{"key": "A-1", "summary": "т2", "labels": []}])
|
||||
assert hub["client"].get("/issues/A-1")["neighbor"] is False
|
||||
25
tests/test_cli.py
Normal file
25
tests/test_cli.py
Normal file
@ -0,0 +1,25 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from jira_hub import cli, db, paths, settings
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
||||
|
||||
|
||||
def test_init_import_tokens(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
|
||||
assert cli.main(["init", "--config", str(FIXTURE)]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "transitions_map" in out and "агентский токен" in out
|
||||
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
|
||||
assert "уже инициализирован" in capsys.readouterr().err
|
||||
conn = db.connect(paths.db_path())
|
||||
assert settings.get(conn, "board_id") == 4216
|
||||
(tmp_path / "pkg").mkdir()
|
||||
(tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8")
|
||||
assert cli.main(["import", str(tmp_path / "pkg")]) == 0
|
||||
rep = json.loads(capsys.readouterr().out)
|
||||
assert rep["notes"] == 1 and rep["missing_keys"] == ["AURORA-1"]
|
||||
assert cli.main(["tokens"]) == 0
|
||||
assert settings.tokens(conn)["client"] in capsys.readouterr().out
|
||||
conn.close()
|
||||
151
tests/test_commands.py
Normal file
151
tests/test_commands.py
Normal file
@ -0,0 +1,151 @@
|
||||
import pytest
|
||||
|
||||
from jira_hub import commands, db, mirror
|
||||
from jira_hub.commands import CommandError
|
||||
|
||||
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
|
||||
|
||||
|
||||
def test_validate_ok_and_tail_keys():
|
||||
cmds = commands.validate([
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Т"}},
|
||||
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
|
||||
{"type": "refresh", "params": {"keys": ["A-1", "new:60"]}},
|
||||
])
|
||||
assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_link", "refresh"]
|
||||
assert commands.referenced_keys(cmds[1]) == ["new:60", "A-1"]
|
||||
assert commands.temp_keys_used(cmds[1]) == ["new:60"]
|
||||
assert commands.temp_keys_used(cmds[0]) == []
|
||||
assert commands.temp_keys_used(cmds[2]) == ["new:60"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad, msg", [
|
||||
([], "без команд"),
|
||||
([{"type": "ensure_all"}], "неизвестный тип"),
|
||||
([{"type": "ensure_fields", "params": {"key": "A-1"}}], "без полей"),
|
||||
([{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "foo": 1}}], "лишние параметры"),
|
||||
([{"type": "ensure_status", "params": {"key": "A-1"}}], "нет target"),
|
||||
([{"type": "refresh", "params": {}}], "без keys и jql"),
|
||||
([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"),
|
||||
([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:<n>"),
|
||||
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"),
|
||||
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}},
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"),
|
||||
])
|
||||
def test_validate_errors(bad, msg):
|
||||
with pytest.raises(CommandError, match=msg):
|
||||
commands.validate(bad)
|
||||
|
||||
|
||||
def test_substitute_and_estimate():
|
||||
p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"})
|
||||
assert p["source"] == "A-9"
|
||||
p = commands.substitute("refresh", {"keys": ["new:60", "A-1"]}, {"new:60": "A-9"})
|
||||
assert p["keys"] == ["A-9", "A-1"]
|
||||
p = commands.substitute("ensure_issue", {"temp_key": "new:60", "issue_type": "task", "summary": "x", "epic_key": "new:2"},
|
||||
{"new:2": "A-7", "new:60": "A-9"})
|
||||
assert p["temp_key"] == "new:60" and p["epic_key"] == "A-7"
|
||||
assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152
|
||||
|
||||
|
||||
def _mirror():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.apply_state(conn, [
|
||||
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
|
||||
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
|
||||
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
|
||||
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
|
||||
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
|
||||
return conn
|
||||
|
||||
|
||||
LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd, expected", [
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "priority": "Высокий"}}, True),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}, False),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["kind-dev"], "labels_remove": ["fact-done"]}}, True),
|
||||
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["kind-dev"]}}, False),
|
||||
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}}, True),
|
||||
({"type": "ensure_status", "params": {"key": "A-1", "target": "Разработка"}}, False),
|
||||
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, True),
|
||||
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "блокирует начало"}}, False),
|
||||
({"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, False),
|
||||
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True),
|
||||
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False),
|
||||
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False),
|
||||
({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
|
||||
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
|
||||
])
|
||||
def test_check_confirmed(cmd, expected):
|
||||
ok, _ = commands.check_confirmed(_mirror(), cmd, LT)
|
||||
assert ok is expected
|
||||
|
||||
|
||||
def test_ensure_link_reverse_direction_with_both_keys_in_mirror():
|
||||
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
|
||||
conn = _mirror()
|
||||
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_ensure_link_conflict_other_type_blocks_confirmation():
|
||||
conn = _mirror()
|
||||
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
|
||||
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_ensure_link_conflict_reverse_blocks_confirmation():
|
||||
conn = _mirror()
|
||||
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
|
||||
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def _ensure_issue(summary):
|
||||
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
|
||||
|
||||
|
||||
def test_check_confirmed_issue_by_summary():
|
||||
conn = _mirror()
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok and result == {"key": "A-1"}
|
||||
ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT, reporter="petrov")
|
||||
assert not ok
|
||||
|
||||
|
||||
def test_ensure_issue_without_reporter_never_confirmed():
|
||||
"""R-11: пока автор неизвестен, совпадение темы не подтверждает ensure_issue."""
|
||||
conn = _mirror()
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
|
||||
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter=None) == (False, None)
|
||||
|
||||
|
||||
def test_ensure_issue_older_than_7_days_not_confirmed():
|
||||
conn = _mirror()
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||
conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'")
|
||||
ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_ensure_issue_reporter_filter():
|
||||
conn = _mirror()
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
|
||||
ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="ivanov")
|
||||
assert ok is False
|
||||
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok and result == {"key": "A-1"}
|
||||
|
||||
|
||||
def test_ensure_issue_two_matches_takes_newest():
|
||||
conn = _mirror()
|
||||
mirror.apply_state(conn, [{"key": "A-3", "summary": "Тема", "reporter": "petrov"}], [])
|
||||
conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-1 day') WHERE key = 'A-1'")
|
||||
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'")
|
||||
ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov")
|
||||
assert ok and result == {"key": "A-3"}
|
||||
71
tests/test_db.py
Normal file
71
tests/test_db.py
Normal file
@ -0,0 +1,71 @@
|
||||
from jira_hub import db
|
||||
|
||||
|
||||
def test_connect_creates_schema():
|
||||
conn = db.connect(":memory:")
|
||||
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
||||
assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "1"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
|
||||
|
||||
def test_connect_is_idempotent(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
db.connect(path).close()
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
|
||||
|
||||
def test_transaction_rolls_back_on_error(tmp_path):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')")
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_nested_transaction_joins_outer(tmp_path):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
with db.transaction(conn):
|
||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'y', 'z')")
|
||||
assert conn.in_transaction # вложенный блок не закоммитил сам
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 1
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 # откат внешней откатил и вложенную
|
||||
|
||||
|
||||
def test_nested_transaction_error_propagates_to_outer(tmp_path):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('a', 'y', 'z')")
|
||||
with db.transaction(conn):
|
||||
raise RuntimeError("inner")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
|
||||
|
||||
|
||||
def test_parse_and_utcnow():
|
||||
assert db.parse("2020-01-01T00:00:00").tzinfo is not None
|
||||
assert db.parse(db.now()) <= db.utcnow()
|
||||
|
||||
|
||||
def test_nested_transaction_rollback_is_local(tmp_path):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
with db.transaction(conn):
|
||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('outer', 'y', 'z')")
|
||||
try:
|
||||
with db.transaction(conn):
|
||||
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('inner', 'y', 'z')")
|
||||
raise RuntimeError("inner")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert [r["title"] for r in conn.execute("SELECT title FROM batch")] == ["outer"]
|
||||
79
tests/test_e2e_hub.py
Normal file
79
tests/test_e2e_hub.py
Normal file
@ -0,0 +1,79 @@
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub import api, settings
|
||||
from jira_hub.client import HubClient, HubError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live(tmp_path):
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
h.claim_wait = 0
|
||||
conn = h.conn()
|
||||
settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"})
|
||||
tok = settings.tokens(conn)
|
||||
conn.close()
|
||||
srv = api.make_server(h, "127.0.0.1", 0)
|
||||
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
th.start()
|
||||
base = f"http://127.0.0.1:{srv.server_port}"
|
||||
yield HubClient(base, tok["agent"]), HubClient(base, tok["client"])
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
th.join(5)
|
||||
|
||||
|
||||
def test_create_link_sprint_flow(live):
|
||||
agent, client = live
|
||||
client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"})
|
||||
b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23"}},
|
||||
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
|
||||
{"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}},
|
||||
]})
|
||||
assert b["count"] == 4
|
||||
bid = b["id"]
|
||||
assert client.get(f"/batches/{bid}")["status"] == "queued"
|
||||
seen = []
|
||||
while True:
|
||||
cmd = agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"]
|
||||
if cmd is None:
|
||||
break
|
||||
seen.append((cmd["type"], cmd["params"]))
|
||||
view = client.get(f"/batches/{bid}")
|
||||
assert view["status"] == "running" and view["remaining_seconds"] > 0
|
||||
if cmd["type"] == "ensure_issue":
|
||||
# одна команда в полёте: зависимая ensure_link не выдаётся до done ensure_issue
|
||||
assert agent.post("/agent/claim", {"agent_version": 1, "settings_version": 1})["command"] is None
|
||||
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"key": "A-90"}, "jira_calls": 2})
|
||||
# done ensure_issue не пишет в зеркало; заметка переехала с new:60 на A-90
|
||||
with pytest.raises(HubError) as e:
|
||||
client.get("/issues/A-90")
|
||||
assert e.value.status == 404
|
||||
with pytest.raises(HubError) as e:
|
||||
client.get("/issues/new:60")
|
||||
assert e.value.status == 404
|
||||
view = client.get(f"/batches/{bid}")
|
||||
assert view["status"] == "running" and view["remaining_seconds"] > 0
|
||||
elif cmd["type"] == "refresh":
|
||||
agent.post("/agent/state", {"command_id": cmd["id"], "issues": [
|
||||
{"key": "A-90", "summary": "Новая", "duedate": "2026-10-23", "sprint": "Спринт 2", "labels": [],
|
||||
"created": "2026-10-11T10:00:00.000+0300"},
|
||||
{"key": "A-1", "summary": "Старая", "labels": []}],
|
||||
"links": [{"source_key": "A-90", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]})
|
||||
assert client.get("/issues/A-90")["summary"] == "Новая"
|
||||
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
|
||||
else:
|
||||
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
|
||||
assert [t for t, _ in seen] == ["ensure_issue", "ensure_link", "ensure_sprint", "refresh"]
|
||||
assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[3][1]["keys"] == ["A-90", "A-1"]
|
||||
view = client.get(f"/batches/{bid}")
|
||||
assert view["status"] == "done" and view["remaining_seconds"] == 0
|
||||
assert all(c["status"] == "done" for c in view["commands"])
|
||||
issue = client.get("/issues/A-90")
|
||||
assert issue["note"]["work_kind"] == "разработка"
|
||||
assert len(issue["links_out"]) == 1
|
||||
assert issue["links_out"][0]["target_key"] == "A-1" and issue["links_out"][0]["link_type"] == "ASUPR_Связанные"
|
||||
assert client.get("/issues/A-1")["links_in"] == [{"source_key": "A-90", "link_type": "ASUPR_Связанные"}]
|
||||
assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2
|
||||
101
tests/test_importer.py
Normal file
101
tests/test_importer.py
Normal file
@ -0,0 +1,101 @@
|
||||
import csv
|
||||
|
||||
from jira_hub import db, importer, mirror, settings
|
||||
|
||||
|
||||
def write_csv(path, header, rows):
|
||||
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(header)
|
||||
w.writerows(rows)
|
||||
|
||||
|
||||
def make_package(folder):
|
||||
write_csv(folder / "tasks.csv",
|
||||
["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации",
|
||||
"Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"],
|
||||
[["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", "Актуализация 10.10.2026. Порядок",
|
||||
"", "Прогнать на стенде", "Спринт 2"],
|
||||
["AURORA-1416", "Карточка", "UI", "В работе", "", "Сделан список", "Модалка", ""]])
|
||||
write_csv(folder / "new_tasks.csv",
|
||||
["Ключ", "Тема", "Вид работы", "Статус по факту", "Описание", "Основание (внутр.)", "Спринт"],
|
||||
[["AURORA-1586", "Справочники: тестирование", "тестирование", "Ожидание разработки", "h3. Контекст", "решение 11.10", "Спринт 2"],
|
||||
["", "Ещё не создана", "разработка", "Ожидание разработки", "h3. Контекст", "идея", ""]])
|
||||
write_csv(folder / "epics.csv", ["Код", "Ключ", "Эпик", "Комментарий"],
|
||||
[["AURORA-1350", "AURORA-1350", "Загрузка", "ядро"], ["NEW-1", "AURORA-1529", "Роли", "новый"]])
|
||||
write_csv(folder / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"],
|
||||
[["AURORA-1533", "блокирует начало", "AURORA-1416", "x"],
|
||||
["new:2", "связанные", "AURORA-1533", "y"],
|
||||
["new:3", "связанные", "AURORA-1533", "z"]])
|
||||
|
||||
|
||||
def test_import_notes_and_report(tmp_path):
|
||||
make_package(tmp_path)
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"})
|
||||
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}],
|
||||
[{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}])
|
||||
rep = importer.import_package(conn, tmp_path)
|
||||
assert rep["notes"] == 6
|
||||
n = mirror.get_note(conn, "AURORA-1533")
|
||||
assert n["fact_status"] == "Ожидание разработки" and n["work_kind"] == "тестирование"
|
||||
assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import"
|
||||
assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список"
|
||||
assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10"
|
||||
assert mirror.get_note(conn, "new:3")["owner_note"] == "Ещё не создана"
|
||||
assert mirror.get_note(conn, "AURORA-1529")["work_kind"] == "эпик"
|
||||
assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"]
|
||||
assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется
|
||||
assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}]
|
||||
assert importer.import_package(conn, tmp_path)["notes"] == 6
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6
|
||||
|
||||
|
||||
def write_tasks(folder, comment):
|
||||
write_csv(folder / "tasks.csv",
|
||||
["Код", "Тема", "Вид работы", "Статус по факту", "Комментарий актуализации",
|
||||
"Что сделано по факту (внутр.)", "Что осталось (внутр.)", "Спринт"],
|
||||
[["AURORA-1533", "Тест ОСВ", "тестирование", "Ожидание разработки", comment, "", "Прогнать", "Спринт 2"]])
|
||||
|
||||
|
||||
def test_reimport_keeps_hub_edit_when_cell_empty(tmp_path):
|
||||
write_tasks(tmp_path, "Актуализация 10.10.2026")
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
importer.import_package(conn, tmp_path)
|
||||
mirror.set_note(conn, "AURORA-1533", {"owner_note": "правка в хабе"}, "hub")
|
||||
write_tasks(tmp_path, "") # пустая ячейка в CSV
|
||||
importer.import_package(conn, tmp_path)
|
||||
n = mirror.get_note(conn, "AURORA-1533")
|
||||
assert n["owner_note"] == "правка в хабе"
|
||||
assert n["rest_text"] == "Прогнать"
|
||||
|
||||
|
||||
def test_reimport_updates_nonempty_cell(tmp_path):
|
||||
write_tasks(tmp_path, "Первая версия")
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
importer.import_package(conn, tmp_path)
|
||||
write_tasks(tmp_path, "Вторая версия")
|
||||
importer.import_package(conn, tmp_path)
|
||||
assert mirror.get_note(conn, "AURORA-1533")["owner_note"] == "Вторая версия"
|
||||
|
||||
|
||||
def test_import_does_not_touch_issue_tables(tmp_path):
|
||||
make_package(tmp_path)
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}],
|
||||
[{"source_key": "AURORA-1533", "target_key": "AURORA-1416", "link_type": "x"}])
|
||||
count = lambda t: conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"]
|
||||
before = (count("issue"), count("issue_link"))
|
||||
importer.import_package(conn, tmp_path)
|
||||
assert (count("issue"), count("issue_link")) == before
|
||||
|
||||
|
||||
def test_missing_csv_files_give_empty_report(tmp_path):
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
rep = importer.import_package(conn, tmp_path)
|
||||
assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": []}
|
||||
186
tests/test_mcp.py
Normal file
186
tests/test_mcp.py
Normal file
@ -0,0 +1,186 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub import api, mcp_server, settings
|
||||
from jira_hub.client import HubClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live(tmp_path, monkeypatch):
|
||||
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||
h.claim_wait = 0
|
||||
conn = h.conn()
|
||||
tok = settings.tokens(conn)
|
||||
conn.close()
|
||||
srv = api.make_server(h, "127.0.0.1", 0)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
base = f"http://127.0.0.1:{srv.server_port}"
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
|
||||
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
|
||||
yield {"agent": HubClient(base, tok["agent"])}
|
||||
srv.shutdown()
|
||||
srv.server_close()
|
||||
|
||||
|
||||
def test_offline_message(monkeypatch):
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
|
||||
assert mcp_server.issues() == mcp_server.OFFLINE
|
||||
assert mcp_server.agent_status() == mcp_server.OFFLINE
|
||||
|
||||
|
||||
def test_tools_roundtrip(live):
|
||||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23",
|
||||
"assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []})
|
||||
out = mcp_server.issues(sprint="Спринт 2")
|
||||
assert "A-1" in out and "Тема раз" in out and "ivanov" in out
|
||||
assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе")
|
||||
detail = mcp_server.issue("A-1")
|
||||
assert "fact_status: В работе" in detail and "Готово" in detail
|
||||
created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}])
|
||||
batch_id = int(created.split("#")[1].split()[0])
|
||||
assert "2 команд" in created and "мин" in created
|
||||
bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}])
|
||||
assert bad.startswith("ошибка хаба 422")
|
||||
cmd = live["agent"].post("/agent/claim", {})["command"]
|
||||
live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"})
|
||||
tail = live["agent"].post("/agent/claim", {})["command"]
|
||||
live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"})
|
||||
waited = asyncio.run(mcp_server.batch_wait(batch_id, timeout_minutes=1))
|
||||
assert "done" in waited and "failed" in waited and "HTTP 400" in waited
|
||||
assert f"#{batch_id}" in mcp_server.batches(limit=5)
|
||||
assert "Повтор команды" in mcp_server.command_retry(cmd["id"])
|
||||
assert "cancelled" in mcp_server.batch_cancel(batch_id + 1)
|
||||
r = asyncio.run(mcp_server.refresh(full=True))
|
||||
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r)
|
||||
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False)))
|
||||
assert "онлайн" in mcp_server.agent_status()
|
||||
assert "120" in mcp_server.settings_get("pacing")
|
||||
assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1}))
|
||||
assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
|
||||
|
||||
|
||||
def test_formatters():
|
||||
assert mcp_server.fmt_issues([]) == "задач нет"
|
||||
text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
|
||||
"sprint": None, "fact_status": None}])
|
||||
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
|
||||
assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
|
||||
"rate_limited_24h": 0, "stuck": [], "warning": None, "last_seen_at": None,
|
||||
"settings_version": None})
|
||||
|
||||
|
||||
def test_settings_set_bad_json(live):
|
||||
assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON")
|
||||
|
||||
|
||||
def _batch(status):
|
||||
return {"id": 7, "title": "т", "author": "claude", "created_at": "2026-10-11", "status": status,
|
||||
"remaining_seconds": 0, "counts": {status: 1}, "commands": []}
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, statuses, runs=None):
|
||||
self.statuses, self.calls, self.runs = list(statuses), 0, runs
|
||||
|
||||
def get(self, path, **params):
|
||||
if path == "/sync-runs":
|
||||
return {"runs": self.runs}
|
||||
self.calls += 1
|
||||
return _batch(self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0])
|
||||
|
||||
|
||||
def _fake_time(monkeypatch, step=1.0):
|
||||
clock = {"now": 0.0, "sleeps": 0, "deadlines": []}
|
||||
|
||||
def monotonic():
|
||||
clock["now"] += step
|
||||
return clock["now"]
|
||||
|
||||
async def sleep(_):
|
||||
clock["sleeps"] += 1
|
||||
|
||||
monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic))
|
||||
monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread))
|
||||
return clock
|
||||
|
||||
|
||||
def test_batch_wait_polls_until_done(monkeypatch):
|
||||
fake = FakeClient(["running", "running", "done"])
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
|
||||
clock = _fake_time(monkeypatch)
|
||||
out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10))
|
||||
assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out
|
||||
assert fake.calls == 3 and clock["sleeps"] == 2
|
||||
|
||||
|
||||
def test_batch_wait_timeout(monkeypatch):
|
||||
fake = FakeClient(["running"])
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
|
||||
clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов
|
||||
out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=1))
|
||||
assert "таймаут ожидания" in out
|
||||
assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)])
|
||||
def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes):
|
||||
fake = FakeClient(["running"])
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
|
||||
clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
|
||||
asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked))
|
||||
# первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна
|
||||
assert fake.calls == minutes
|
||||
|
||||
|
||||
def test_client_created_once_per_wait(monkeypatch):
|
||||
fake, made = FakeClient(["running", "running", "done"]), []
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake)
|
||||
_fake_time(monkeypatch)
|
||||
asyncio.run(mcp_server.batch_wait(7))
|
||||
assert len(made) == 1
|
||||
|
||||
|
||||
def test_sync_log_content(monkeypatch):
|
||||
runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None,
|
||||
"issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}]
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs))
|
||||
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1"
|
||||
|
||||
|
||||
def test_async_tools_are_coroutines_and_registered():
|
||||
assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh)
|
||||
tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}
|
||||
assert {"batch_wait", "refresh"} <= set(tools)
|
||||
assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False
|
||||
|
||||
|
||||
def test_async_guard_offline(monkeypatch):
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
|
||||
assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE
|
||||
assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE
|
||||
|
||||
|
||||
def test_batch_wait_does_not_block_event_loop(monkeypatch):
|
||||
"""Пока идёт ожидание, другая задача event loop продолжает выполняться."""
|
||||
fake = FakeClient(["running", "running", "done"])
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
|
||||
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01)
|
||||
|
||||
async def scenario():
|
||||
ticks = []
|
||||
|
||||
async def ticker():
|
||||
for _ in range(5):
|
||||
ticks.append(1)
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
out, _ = await asyncio.gather(mcp_server.batch_wait(7), ticker())
|
||||
return out, ticks
|
||||
|
||||
out, ticks = asyncio.run(scenario())
|
||||
assert "done" in out and len(ticks) == 5
|
||||
103
tests/test_mirror.py
Normal file
103
tests/test_mirror.py
Normal file
@ -0,0 +1,103 @@
|
||||
import pytest
|
||||
|
||||
from jira_hub import db, mirror
|
||||
|
||||
|
||||
def issue(key, **over):
|
||||
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
|
||||
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
|
||||
"duedate": "2026-10-23", "planned_start": None, "planned_end": "2026-10-23", "epic_key": "AURORA-1350",
|
||||
"labels": ["AWPCA"], "sprint": "Спринт 2", "created": "2026-10-10T12:00:00.000+0300",
|
||||
"jira_updated": "2026-10-11T12:00:00.000+0300"}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def test_apply_state_upserts_and_replaces_links():
|
||||
conn = db.connect(":memory:")
|
||||
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
|
||||
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
|
||||
assert out == {"issues": 2, "links": 1}
|
||||
mirror.apply_state(conn, [issue("A-1", summary="Новая тема")],
|
||||
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
|
||||
got = mirror.get_issue(conn, "A-1")
|
||||
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
|
||||
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}]
|
||||
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
|
||||
|
||||
|
||||
def test_links_from_other_source_survive():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
|
||||
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
|
||||
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
|
||||
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
|
||||
|
||||
|
||||
def test_apply_state_marks_neighbors():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
|
||||
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
||||
assert mirror.get_issue(conn, "A-1")["neighbor"] is False
|
||||
mirror.apply_state(conn, [issue("B-9")], []) # вошла в базовую JQL
|
||||
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
|
||||
assert mirror.known_keys(conn) == ["A-1", "B-9"]
|
||||
|
||||
|
||||
def test_partial_load_never_lowers_neighbor():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"})
|
||||
mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys}
|
||||
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
|
||||
mirror.apply_state(conn, [issue("B-9")], [], authoritative=True) # полный прогон: вошла в базовую
|
||||
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
|
||||
|
||||
|
||||
def test_note_survives_refresh_and_renames():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
|
||||
mirror.rename_note(conn, "new:7", "A-5")
|
||||
mirror.apply_state(conn, [issue("A-5")], [])
|
||||
got = mirror.get_issue(conn, "A-5")
|
||||
assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import"
|
||||
assert mirror.get_note(conn, "new:7") is None
|
||||
with pytest.raises(ValueError):
|
||||
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
|
||||
|
||||
|
||||
def test_list_issues_filters():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
|
||||
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
|
||||
issue("A-3", sprint="Спринт 2", duedate=None)], [])
|
||||
mirror.set_note(conn, "A-3", {"work_kind": "тестирование"}, "x")
|
||||
keys = [i["key"] for i in mirror.list_issues(conn, {"sprint": "Спринт 2"})]
|
||||
assert keys == ["A-1", "A-3"] # без срока — последней
|
||||
assert [i["key"] for i in mirror.list_issues(conn, {"label": "fact-done"})] == ["A-2"]
|
||||
assert [i["key"] for i in mirror.list_issues(conn, {"work_kind": "тестирование"})] == ["A-3"]
|
||||
assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"]
|
||||
assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None
|
||||
assert len(mirror.list_issues(conn, None, limit=1)) == 1
|
||||
|
||||
|
||||
def test_rename_note_same_key_keeps_note():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.apply_state(conn, [issue("A-1")], [])
|
||||
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
|
||||
mirror.rename_note(conn, "A-1", "A-1")
|
||||
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
|
||||
|
||||
|
||||
def test_rename_note_missing_is_noop():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
|
||||
assert mirror.get_note(conn, "new:404") is None
|
||||
assert mirror.get_note(conn, "A-9") is None
|
||||
|
||||
|
||||
def test_set_note_partial_keeps_other_fields():
|
||||
conn = db.connect(":memory:")
|
||||
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
|
||||
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
|
||||
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
|
||||
assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
|
||||
297
tests/test_queue.py
Normal file
297
tests/test_queue.py
Normal file
@ -0,0 +1,297 @@
|
||||
import pytest
|
||||
|
||||
from jira_hub import db, mirror, queue, settings
|
||||
from jira_hub.commands import CommandError
|
||||
from jira_hub.queue import ClaimedError
|
||||
|
||||
LT = {"связанные": "ASUPR_Связанные"}
|
||||
|
||||
|
||||
def fresh():
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
settings.set_(conn, "link_types", LT)
|
||||
return conn
|
||||
|
||||
|
||||
def fields(key, **extra):
|
||||
return {"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23", **extra}}
|
||||
|
||||
|
||||
def test_create_batch_adds_tail_refresh_and_estimate():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "срок", "claude", [fields("A-1"), {"type": "ensure_comment", "params": {"key": "A-2", "marker": "m", "body": "b"}}])
|
||||
view = queue.batch_view(conn, b["id"])
|
||||
assert [c["type"] for c in view["commands"]] == ["ensure_fields", "ensure_comment", "refresh"]
|
||||
assert view["commands"][2]["params"] == {"keys": ["A-1", "A-2"]}
|
||||
assert b["estimate_seconds"] == 120 + 30 + 2 and b["count"] == 3
|
||||
assert view["status"] == "queued" and view["author"] == "claude"
|
||||
|
||||
|
||||
def test_refresh_only_batch_has_no_tail():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||||
assert b["count"] == 1
|
||||
with pytest.raises(CommandError):
|
||||
queue.create_batch(conn, "x", "claude", [{"type": "nope"}])
|
||||
|
||||
|
||||
def test_claim_order_pause_and_report():
|
||||
conn = fresh()
|
||||
b1 = queue.create_batch(conn, "первый", "claude", [fields("A-1")])
|
||||
b2 = queue.create_batch(conn, "второй", "claude", [fields("A-2")])
|
||||
c = queue.claim(conn, agent_version=1, settings_version=1)
|
||||
assert c["type"] == "ensure_fields" and c["params"]["key"] == "A-1" and c["pause_seconds"] == 120 and c["batch_id"] == b1["id"]
|
||||
assert queue.claim(conn) is None # одна команда в полёте
|
||||
assert queue.agent_view(conn)["current_command_id"] == c["id"]
|
||||
done = queue.report_result(conn, c["id"], "done", result={"changed": ["duedate"]}, jira_calls=2)
|
||||
assert done["status"] == "done" and done["jira_calls"] == 2
|
||||
again = queue.report_result(conn, c["id"], "failed", error="поздно")
|
||||
assert again["status"] == "done" # повторный отчёт не меняет
|
||||
tail = queue.claim(conn)
|
||||
assert tail["type"] == "refresh" and tail["pause_seconds"] == 2
|
||||
assert conn.execute("SELECT kind, query FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()["query"] == "A-1"
|
||||
queue.record_state(conn, tail["id"], 1, 0)
|
||||
queue.report_result(conn, tail["id"], "done", jira_calls=1)
|
||||
run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()
|
||||
assert run["finished_at"] and run["issues_count"] == 1 and run["jira_calls"] == 1
|
||||
assert queue.batch_view(conn, b1["id"])["status"] == "done"
|
||||
assert queue.claim(conn)["params"]["key"] == "A-2" and queue.batch_view(conn, b2["id"])["status"] == "running"
|
||||
|
||||
|
||||
def test_claim_is_exclusive(tmp_path):
|
||||
import threading
|
||||
path = tmp_path / "hub.sqlite"
|
||||
setup = db.connect(path)
|
||||
settings.ensure_defaults(setup)
|
||||
queue.create_batch(setup, "x", "claude", [fields("A-1")])
|
||||
conns = [db.connect(path), db.connect(path)]
|
||||
barrier, results = threading.Barrier(2), []
|
||||
|
||||
def worker(c):
|
||||
barrier.wait()
|
||||
results.append(queue.claim(c))
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(c,)) for c in conns]
|
||||
[t.start() for t in threads]
|
||||
[t.join() for t in threads]
|
||||
assert sorted(r is None for r in results) == [False, True]
|
||||
assert setup.execute("SELECT COUNT(*) AS n FROM command WHERE status = 'claimed'").fetchone()["n"] == 1
|
||||
|
||||
|
||||
def test_backup_failure_does_not_block_claim(caplog):
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||
|
||||
def broken(label):
|
||||
raise OSError("диск полон")
|
||||
|
||||
c = queue.claim(conn, backup=broken)
|
||||
assert c is not None and c["params"]["key"] == "A-1"
|
||||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||||
assert "не создана" in caplog.text
|
||||
|
||||
|
||||
def test_stale_claimed_does_not_block():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||
c = queue.claim(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||||
nxt = queue.claim(conn)
|
||||
assert nxt["params"]["key"] == "A-2"
|
||||
assert queue.agent_view(conn)["stuck"][0]["id"] == c["id"]
|
||||
|
||||
|
||||
def test_temp_key_substitution_and_skip():
|
||||
conn = fresh()
|
||||
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
|
||||
queue.create_batch(conn, "создать", "claude", [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
|
||||
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая"}},
|
||||
{"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}},
|
||||
])
|
||||
c1 = queue.claim(conn)
|
||||
queue.report_result(conn, c1["id"], "done", result={"key": "A-90"})
|
||||
assert mirror.get_note(conn, "A-90")["fact_status"] == "В работе" and mirror.get_note(conn, "new:60") is None
|
||||
c2 = queue.claim(conn)
|
||||
assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90"
|
||||
queue.report_result(conn, c2["id"], "done")
|
||||
c3 = queue.claim(conn)
|
||||
queue.report_result(conn, c3["id"], "failed", error="HTTP 400")
|
||||
tail = queue.claim(conn) # ensure_sprint пропущена, refresh без new:61
|
||||
assert tail["type"] == "refresh" and tail["params"]["keys"] == ["A-90", "A-1"]
|
||||
view = queue.batch_view(conn, c1["batch_id"])
|
||||
assert [c["status"] for c in view["commands"]][:4] == ["done", "done", "failed", "skipped"]
|
||||
assert view["commands"][3]["error"] == "задача new:61 не создана"
|
||||
|
||||
|
||||
def test_cancel_and_retry():
|
||||
conn = fresh()
|
||||
b = queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||
c = queue.claim(conn)
|
||||
with pytest.raises(ClaimedError):
|
||||
queue.cancel_command(conn, c["id"])
|
||||
view = queue.cancel_batch(conn, b["id"])
|
||||
assert [x["status"] for x in view["commands"]] == ["claimed", "cancelled", "cancelled"]
|
||||
assert queue.claim(conn) is None
|
||||
forced = queue.cancel_command(conn, c["id"], force=True)
|
||||
assert forced["status"] == "cancelled"
|
||||
after = queue.report_result(conn, c["id"], "done", result={"changed": []})
|
||||
assert after["status"] == "cancelled" and after["result"] is None # отчёт после force-отмены не затирает
|
||||
assert queue.batch_view(conn, b["id"])["status"] == "cancelled"
|
||||
r = queue.retry_command(conn, c["id"], "claude")
|
||||
assert queue.batch_view(conn, r["id"])["title"] == f"Повтор команды {c['id']}"
|
||||
assert queue.list_batches(conn, limit=5)[0]["id"] == r["id"]
|
||||
|
||||
|
||||
def test_report_after_force_cancel_keeps_cancelled():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1")])
|
||||
c = queue.claim(conn)
|
||||
queue.cancel_command(conn, c["id"], force=True)
|
||||
assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_reconcile_confirms_by_state():
|
||||
conn = fresh()
|
||||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
||||
c = queue.claim(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
||||
r = queue.claim(conn)
|
||||
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], [])
|
||||
queue.report_result(conn, r["id"], "done")
|
||||
assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state"
|
||||
assert r["batch_id"] == c["batch_id"] # выдался хвостовой refresh первого пакета
|
||||
assert queue.batch_view(conn, c["batch_id"])["status"] == "done"
|
||||
assert queue.batch_view(conn, b["id"])["status"] == "queued"
|
||||
|
||||
|
||||
def test_auto_refresh_conditions():
|
||||
conn = fresh()
|
||||
assert queue.maybe_auto_refresh(conn) is None # агент не на связи
|
||||
queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста
|
||||
b = queue.maybe_auto_refresh(conn)
|
||||
assert b and queue.batch_view(conn, b["id"])["title"] == "Автообновление"
|
||||
assert queue.maybe_auto_refresh(conn) is None # очередь занята
|
||||
c = queue.claim(conn)
|
||||
queue.report_result(conn, c["id"], "done")
|
||||
assert queue.maybe_auto_refresh(conn) is None # интервал не прошёл
|
||||
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
|
||||
assert queue.maybe_auto_refresh(conn) is not None
|
||||
settings.set_(conn, "auto_refresh_minutes", 0)
|
||||
conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'")
|
||||
assert queue.maybe_auto_refresh(conn) is None
|
||||
|
||||
|
||||
def test_backup_called_for_first_write_only(tmp_path):
|
||||
conn = fresh()
|
||||
calls = []
|
||||
queue.create_batch(conn, "x", "claude", [fields("A-1"), fields("A-2")])
|
||||
c1 = queue.claim(conn, backup=calls.append)
|
||||
queue.report_result(conn, c1["id"], "done")
|
||||
queue.claim(conn, backup=calls.append)
|
||||
assert calls == [f"batch-{c1['batch_id']}"]
|
||||
|
||||
|
||||
def test_no_backup_for_refresh_batch():
|
||||
conn = fresh()
|
||||
calls = []
|
||||
queue.create_batch(conn, "x", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||||
queue.claim(conn, backup=calls.append)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_make_backup_rotates(tmp_path):
|
||||
from jira_hub import backup
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
for i in range(4):
|
||||
backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2)
|
||||
names = sorted(p.name for p in (tmp_path / "backups").glob("*.sqlite"))
|
||||
assert len(names) == 2 and names[-1].endswith("-b3.sqlite")
|
||||
assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
|
||||
|
||||
def test_reconcile_respects_reporter_setting():
|
||||
conn = fresh()
|
||||
settings.set_(conn, "reporter", "robot")
|
||||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}])
|
||||
c = queue.claim(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||||
created = db.utcnow().isoformat()
|
||||
mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], [])
|
||||
assert queue.reconcile(conn) == [] # чужая задача не подтверждает
|
||||
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], [])
|
||||
assert queue.reconcile(conn) == [c["id"]]
|
||||
assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"}
|
||||
|
||||
|
||||
def _claimed_ensure_issue(conn, summary="Тема"):
|
||||
queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}])
|
||||
c = queue.claim(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],))
|
||||
return c
|
||||
|
||||
|
||||
def test_reconcile_does_not_confirm_ensure_issue_without_reporter():
|
||||
"""R-11: настройка reporter не задана — совпадение темы не подтверждает, заметка new:N не переезжает."""
|
||||
conn = fresh()
|
||||
c = _claimed_ensure_issue(conn)
|
||||
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
|
||||
created = db.utcnow().isoformat()
|
||||
mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], [])
|
||||
assert queue.reconcile(conn) == []
|
||||
assert queue.get_command(conn, c["id"])["status"] == "claimed"
|
||||
assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None
|
||||
|
||||
|
||||
def test_reconcile_does_not_overwrite_finished_command(monkeypatch):
|
||||
"""M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются."""
|
||||
conn = fresh()
|
||||
settings.set_(conn, "reporter", "robot")
|
||||
c = _claimed_ensure_issue(conn)
|
||||
mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t")
|
||||
mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot",
|
||||
"created": db.utcnow().isoformat(), "labels": []}], [])
|
||||
real = queue.C.check_confirmed
|
||||
|
||||
def racing(*a, **kw):
|
||||
out = real(*a, **kw)
|
||||
queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(queue.C, "check_confirmed", racing)
|
||||
assert queue.reconcile(conn) == []
|
||||
assert queue.get_command(conn, c["id"])["status"] == "cancelled"
|
||||
assert mirror.get_note(conn, "new:1") is not None # rename_note не выполнялся
|
||||
|
||||
|
||||
def _two_batches_with_dependent(conn):
|
||||
a = queue.create_batch(conn, "A", "claude", [
|
||||
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}},
|
||||
{"type": "ensure_link", "params": {"source": "new:1", "target": "A-1", "link_type": "связанные"}}])
|
||||
first = queue.claim(conn)
|
||||
assert first["type"] == "ensure_issue"
|
||||
b = queue.create_batch(conn, "B", "claude", [{"type": "refresh", "params": {"jql": "project = X"}}])
|
||||
return a, b, first
|
||||
|
||||
|
||||
def test_stale_definer_does_not_block_other_batches():
|
||||
"""R-13: зависший ensure_issue с зависимыми не держит очередь целиком."""
|
||||
conn = fresh()
|
||||
a, b, first = _two_batches_with_dependent(conn)
|
||||
conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (first["id"],))
|
||||
got = queue.claim(conn)
|
||||
assert got is not None and got["batch_id"] == b["id"] and got["type"] == "refresh"
|
||||
# остаток пакета A остаётся в очереди, не выдаётся и не пропускается
|
||||
assert [c["status"] for c in queue.batch_view(conn, a["id"])["commands"]][1:] == ["queued", "queued"]
|
||||
|
||||
|
||||
def test_fresh_definer_still_blocks_queue():
|
||||
conn = fresh()
|
||||
a, b, first = _two_batches_with_dependent(conn)
|
||||
assert queue.claim(conn) is None # одна в полёте
|
||||
conn.execute("UPDATE command SET status = 'done', result = '{\"key\": \"A-9\"}' WHERE id = ?", (first["id"],))
|
||||
nxt = queue.claim(conn)
|
||||
assert nxt["batch_id"] == a["id"] and nxt["params"]["source"] == "A-9"
|
||||
55
tests/test_settings.py
Normal file
55
tests/test_settings.py
Normal file
@ -0,0 +1,55 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub import db, settings
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
|
||||
|
||||
|
||||
def fresh():
|
||||
conn = db.connect(":memory:")
|
||||
settings.ensure_defaults(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def test_defaults_and_tokens():
|
||||
conn = fresh()
|
||||
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA"
|
||||
assert settings.get(conn, "pacing")["ensure_fields"] == 120
|
||||
tok = settings.tokens(conn)
|
||||
assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"]
|
||||
assert "tokens" not in settings.get_all(conn)
|
||||
|
||||
|
||||
def test_set_bumps_version():
|
||||
conn = fresh()
|
||||
v0 = settings.get(conn, "settings_version")
|
||||
v1 = settings.set_(conn, "pacing", {"ensure_fields": 5})
|
||||
assert v1 == v0 + 1
|
||||
assert settings.get(conn, "pacing") == {"ensure_fields": 5}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["tokens", "settings_version", "schema_version"])
|
||||
def test_protected_keys(key):
|
||||
conn = fresh()
|
||||
with pytest.raises(ValueError):
|
||||
settings.set_(conn, key, "x")
|
||||
|
||||
|
||||
def test_init_from_config():
|
||||
conn = fresh()
|
||||
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
keys = settings.init_from_config(conn, cfg)
|
||||
assert "transitions_map" in keys
|
||||
assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802"
|
||||
assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"}
|
||||
assert settings.get(conn, "board_id") == 4216
|
||||
assert settings.get(conn, "pacing") == settings.DEFAULT_PACING
|
||||
agent_view = settings.for_agent(conn)
|
||||
assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view
|
||||
assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные"
|
||||
with pytest.raises(RuntimeError):
|
||||
settings.init_from_config(conn, cfg)
|
||||
settings.init_from_config(conn, cfg, force=True)
|
||||
705
uv.lock
generated
Normal file
705
uv.lock
generated
Normal file
@ -0,0 +1,705 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[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 = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jira-hub"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "mcp", specifier = ">=1.2,<2" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.50.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "312"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.54.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user