Модуль wiki, агенты модулей, поля проектов (#91) #92
@ -3,7 +3,10 @@ name = "jira-hub"
|
||||
version = "0.1.0"
|
||||
description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["mcp>=1.2,<2"]
|
||||
dependencies = [
|
||||
"markdownify>=1.2.3",
|
||||
"mcp>=1.2,<2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jira-hub = "jira_hub.cli:main"
|
||||
|
||||
@ -7,9 +7,9 @@ _cache: list[Module] | None = None
|
||||
def modules() -> list[Module]:
|
||||
global _cache
|
||||
if _cache is None:
|
||||
from ..modules import jira # внутри функции: ядро не зависит от модулей при импорте
|
||||
from ..modules import jira, wiki # внутри функции: ядро не зависит от модулей при импорте
|
||||
from . import settings
|
||||
mods = [jira.MODULE]
|
||||
mods = [jira.MODULE, wiki.MODULE]
|
||||
# маршруты ядра проверяет core/api.py при сборке ROUTES: здесь их ещё нет
|
||||
check(mods, [], settings.CORE_DEFAULTS)
|
||||
_cache = mods
|
||||
|
||||
18
src/jira_hub/modules/wiki/__init__.py
Normal file
18
src/jira_hub/modules/wiki/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Модуль wiki: Markdown-копия вики Eva по проектам, поиск, вложения по запросу."""
|
||||
from ...core.modules import Module
|
||||
from . import commands, projects, schema, settings, tick
|
||||
|
||||
MODULE = Module(
|
||||
name="wiki",
|
||||
schema=schema.SQL,
|
||||
settings_defaults=settings.DEFAULTS,
|
||||
agent_keys=tuple(settings.AGENT_KEYS),
|
||||
agent_min_version_key="wiki_min_agent_version",
|
||||
validate_setting=settings.validate_setting,
|
||||
commands=commands.HOOKS,
|
||||
tick=(tick.auto_sync_tick,),
|
||||
project_fields=projects.FIELDS,
|
||||
project_view=projects.view,
|
||||
on_project_created=projects.on_created,
|
||||
on_project_updated=projects.on_updated,
|
||||
)
|
||||
81
src/jira_hub/modules/wiki/commands.py
Normal file
81
src/jira_hub/modules/wiki/commands.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Команды модуля wiki: wiki_sync (синхронизация дерева проекта) и wiki_fetch (файл вложения). Обе читают Eva."""
|
||||
import re
|
||||
|
||||
from ...core import queue
|
||||
from ...core.errors import CommandError
|
||||
from ...core.modules import CommandHooks
|
||||
|
||||
TYPES = {"wiki_sync", "wiki_fetch"}
|
||||
SPEC = {"wiki_sync": {"required": ["label", "root"], "optional": []},
|
||||
"wiki_fetch": {"required": ["attachment_id", "url"], "optional": []}}
|
||||
_ROOT = re.compile(r"^DOC-\d+$")
|
||||
|
||||
|
||||
def validate(cmds) -> list[dict]:
|
||||
if not isinstance(cmds, list) or not cmds:
|
||||
raise CommandError("пакет без команд")
|
||||
out = []
|
||||
for i, cmd in enumerate(cmds, 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)}; ожидаются {', '.join(spec['required'])}")
|
||||
unknown = set(params) - set(spec["required"])
|
||||
if unknown:
|
||||
raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}; "
|
||||
f"ожидаются {', '.join(spec['required'])}")
|
||||
if ctype == "wiki_sync":
|
||||
if not isinstance(params["label"], str):
|
||||
raise CommandError(f"команда {i}: label — строка")
|
||||
if not isinstance(params["root"], str) or not _ROOT.match(params["root"]):
|
||||
raise CommandError(f"команда {i}: root — код DOC-<цифры>")
|
||||
else:
|
||||
a = params["attachment_id"]
|
||||
if not isinstance(a, int) or isinstance(a, bool):
|
||||
raise CommandError(f"команда {i}: attachment_id — целое")
|
||||
if not isinstance(params["url"], str):
|
||||
raise CommandError(f"команда {i}: url — строка")
|
||||
out.append({"type": ctype, "params": params})
|
||||
return out
|
||||
|
||||
|
||||
def check_batch(conn, cmds, project, files_dir) -> list[dict]:
|
||||
"""wiki_sync живёт в пакете своего проекта: project пакета равен label команды."""
|
||||
for i, c in enumerate(cmds, 1):
|
||||
if c["type"] == "wiki_sync" and c["params"]["label"] != project:
|
||||
raise CommandError(f"команда {i}: проект пакета ({project}) не равен label ({c['params']['label']})")
|
||||
return cmds
|
||||
|
||||
|
||||
def enqueue_sync(conn, label: str, root: str, author: str) -> dict:
|
||||
"""Пакет «Вики <метка>» из одной команды wiki_sync."""
|
||||
return queue.create_batch(conn, f"Вики {label}", author, [{"type": "wiki_sync", "params": {"label": label, "root": root}}],
|
||||
project=label)
|
||||
|
||||
|
||||
def active_sync(conn, label: str) -> dict | None:
|
||||
"""Пакет wiki_sync проекта не в терминальном статусе (есть команда queued или claimed) или None."""
|
||||
row = conn.execute(
|
||||
"SELECT b.id FROM batch b WHERE b.project = ? AND EXISTS (SELECT 1 FROM command c WHERE c.batch_id = b.id "
|
||||
"AND c.type = 'wiki_sync' AND c.status IN ('queued', 'claimed')) ORDER BY b.id DESC LIMIT 1", (label,)).fetchone()
|
||||
return queue.batch_view(conn, row["id"], with_commands=False) if row else None
|
||||
|
||||
|
||||
_noop = lambda *a, **k: None # noqa: E731
|
||||
|
||||
# Хуки результата пустые: их заполняют приём пачек и отчёт синхронизации
|
||||
HOOKS = CommandHooks(
|
||||
types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch,
|
||||
tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"],
|
||||
temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params,
|
||||
drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"],
|
||||
check_confirmed=lambda conn, cmd: (False, None), on_claim=_noop, on_failed=_noop,
|
||||
check_result=lambda conn, cmd, status, result, files_dir: (status, None), on_result=_noop, after_result=_noop,
|
||||
on_late_result=lambda *a: False)
|
||||
21
src/jira_hub/modules/wiki/paths.py
Normal file
21
src/jira_hub/modules/wiki/paths.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Где лежит Markdown-копия вики: <каталог базы>/wiki/<метка>/."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def root(conn) -> Path:
|
||||
"""Каталог копий всех проектов — рядом с файлом базы. База в памяти файла не имеет: RuntimeError."""
|
||||
row = conn.execute("PRAGMA database_list").fetchone()
|
||||
if row is None or not row[2]:
|
||||
raise RuntimeError("у базы нет файла: каталог вики не определён")
|
||||
return Path(row[2]).parent / "wiki"
|
||||
|
||||
|
||||
def dir_name(label: str) -> str:
|
||||
"""Имя каталога проекта: метка, у которой «/», «\\» и «%» заменены, а «.» и «..» закрыты, чтобы метка не выводила
|
||||
за каталог вики."""
|
||||
name = label.replace("%", "%25").replace("/", "%2F").replace("\\", "%5C")
|
||||
return {".": "%2E", "..": "%2E%2E"}.get(name, name)
|
||||
|
||||
|
||||
def project_dir(conn, label: str) -> Path:
|
||||
return root(conn) / dir_name(label)
|
||||
93
src/jira_hub/modules/wiki/projects.py
Normal file
93
src/jira_hub/modules/wiki/projects.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""Корень вики проекта: поле wiki_root, значения для GET /projects, хуки создания и правки проекта."""
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from ...core import db, queue
|
||||
from ...core.modules import ProjectField
|
||||
from . import commands, paths
|
||||
|
||||
_CODE = re.compile(r"(?<![A-Za-z0-9])DOC-(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_root(value) -> str | None:
|
||||
"""Код корня: None/"" — нет корня; строка или ссылка с DOC-<цифры> — код в верхнем регистре; иначе ValueError."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
m = _CODE.search(value) if isinstance(value, str) else None
|
||||
if m is None:
|
||||
raise ValueError("ожидается код DOC-… или ссылка на документ Eva")
|
||||
return f"DOC-{m.group(1)}"
|
||||
|
||||
|
||||
FIELDS = (ProjectField("wiki_root", parse_root, editable=True),)
|
||||
|
||||
|
||||
def view(conn, labels: list[str]) -> dict[str, dict]:
|
||||
"""Поля проекта для списка меток; без корня все значения None."""
|
||||
if not labels:
|
||||
return {}
|
||||
marks = ", ".join("?" * len(labels))
|
||||
rows = {r["label"]: r for r in conn.execute(f"SELECT * FROM wiki_project WHERE label IN ({marks})", labels)}
|
||||
out = {}
|
||||
for label in labels:
|
||||
r = rows.get(label)
|
||||
out[label] = {"wiki_root": r["root_code"] if r else None, "wiki_root_title": r["root_title"] if r else None,
|
||||
"wiki_pages": r["pages_count"] if r else None, "wiki_synced_at": r["synced_at"] if r else None,
|
||||
"wiki_sync_error": r["sync_error"] if r else None}
|
||||
return out
|
||||
|
||||
|
||||
def on_created(conn, project, fields, author) -> dict | None:
|
||||
"""Проект создан (в его транзакции): с корнем — строка wiki_project и пакет синхронизации."""
|
||||
root = fields.get("wiki_root")
|
||||
if root is None:
|
||||
return None
|
||||
conn.execute("INSERT INTO wiki_project(label, root_code) VALUES (?, ?)", (project["label"], root))
|
||||
return commands.enqueue_sync(conn, project["label"], root, author)
|
||||
|
||||
|
||||
def _cancel_queued(conn, label: str) -> None:
|
||||
"""Отменить пакеты wiki_sync проекта, ни одна команда которых ещё не выдана."""
|
||||
ids = [r["id"] for r in conn.execute(
|
||||
"SELECT b.id FROM batch b WHERE b.project = ? AND b.cancelled_at IS NULL "
|
||||
"AND EXISTS (SELECT 1 FROM command c WHERE c.batch_id = b.id AND c.type = 'wiki_sync' AND c.status = 'queued') "
|
||||
"AND NOT EXISTS (SELECT 1 FROM command c WHERE c.batch_id = b.id AND c.status NOT IN ('queued'))", (label,))]
|
||||
for batch_id in ids:
|
||||
queue.cancel_batch(conn, batch_id)
|
||||
|
||||
|
||||
def _has_table(conn, name: str) -> bool:
|
||||
return conn.execute("SELECT 1 FROM sqlite_master WHERE name = ?", (name,)).fetchone() is not None
|
||||
|
||||
|
||||
def _drop_copy(conn, label: str) -> None:
|
||||
"""Сброс корня: строки проекта, индекс поиска (если он уже создан) и каталог копии."""
|
||||
if _has_table(conn, "wiki_page_fts"):
|
||||
conn.execute("DELETE FROM wiki_page_fts WHERE rowid IN (SELECT rowid FROM wiki_page WHERE label = ?)", (label,))
|
||||
conn.execute("DELETE FROM wiki_page WHERE label = ?", (label,))
|
||||
conn.execute("DELETE FROM wiki_attachment WHERE label = ?", (label,))
|
||||
conn.execute("DELETE FROM wiki_project WHERE label = ?", (label,))
|
||||
try:
|
||||
shutil.rmtree(paths.project_dir(conn, label), ignore_errors=True)
|
||||
except RuntimeError: # база без файла (тесты в памяти): копии на диске нет
|
||||
pass
|
||||
|
||||
|
||||
def on_updated(conn, project, fields, author) -> list[dict]:
|
||||
"""Корень изменён (в транзакции PATCH): невыданные пакеты проекта отменяются; новый корень — пакет синхронизации,
|
||||
прежняя копия остаётся до успеха; None — копия, страницы, индекс и вложения проекта удаляются."""
|
||||
if "wiki_root" not in fields:
|
||||
return []
|
||||
label, root = project["label"], fields["wiki_root"]
|
||||
row = conn.execute("SELECT root_code FROM wiki_project WHERE label = ?", (label,)).fetchone()
|
||||
if (row["root_code"] if row else None) == root:
|
||||
return []
|
||||
_cancel_queued(conn, label)
|
||||
if root is None:
|
||||
_drop_copy(conn, label)
|
||||
return []
|
||||
if row is None:
|
||||
conn.execute("INSERT INTO wiki_project(label, root_code) VALUES (?, ?)", (label, root))
|
||||
else:
|
||||
conn.execute("UPDATE wiki_project SET root_code = ? WHERE label = ?", (root, label))
|
||||
return [commands.enqueue_sync(conn, label, root, author)]
|
||||
46
src/jira_hub/modules/wiki/schema.py
Normal file
46
src/jira_hub/modules/wiki/schema.py
Normal file
@ -0,0 +1,46 @@
|
||||
"""Схема базы модуля wiki: корень проекта, страницы копии, вложения, стадия синхронизации."""
|
||||
|
||||
SQL = """
|
||||
CREATE TABLE IF NOT EXISTS wiki_project (
|
||||
label TEXT PRIMARY KEY REFERENCES project(label),
|
||||
root_code TEXT NOT NULL,
|
||||
root_title TEXT,
|
||||
synced_at TEXT,
|
||||
failed_at TEXT,
|
||||
pages_count INTEGER NOT NULL DEFAULT 0,
|
||||
sync_error TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS wiki_page (
|
||||
label TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
eva_id TEXT,
|
||||
title TEXT,
|
||||
parent_code TEXT,
|
||||
orderno INTEGER,
|
||||
depth INTEGER,
|
||||
path TEXT,
|
||||
url TEXT,
|
||||
size INTEGER,
|
||||
synced_at TEXT,
|
||||
PRIMARY KEY (label, code)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS wiki_attachment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL,
|
||||
page_code TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
name TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'none',
|
||||
file TEXT,
|
||||
size INTEGER,
|
||||
error TEXT,
|
||||
fetched_at TEXT,
|
||||
UNIQUE (label, page_code, url)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS wiki_sync_stage (
|
||||
command_id INTEGER NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (command_id, code)
|
||||
);
|
||||
"""
|
||||
26
src/jira_hub/modules/wiki/settings.py
Normal file
26
src/jira_hub/modules/wiki/settings.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Настройки модуля wiki: умолчания, ключи для агента, проверка значений."""
|
||||
|
||||
DEFAULTS = {
|
||||
"wiki_pacing": 0.2, # пауза агента между запросами к Eva, с
|
||||
"wiki_auto_sync_hours": 24, # фоновая синхронизация, ч; 0 — выключена
|
||||
"wiki_min_agent_version": 1,
|
||||
}
|
||||
|
||||
AGENT_KEYS = ["wiki_pacing"]
|
||||
|
||||
|
||||
def _is_int(v) -> bool:
|
||||
return isinstance(v, int) and not isinstance(v, bool)
|
||||
|
||||
|
||||
def validate_setting(key, value) -> bool:
|
||||
"""True — ключ принадлежит модулю и проверен; ValueError — значение плохое (422 в API)."""
|
||||
if key == "wiki_pacing":
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 10:
|
||||
raise ValueError("wiki_pacing — число 0..10")
|
||||
return True
|
||||
if key in ("wiki_auto_sync_hours", "wiki_min_agent_version"):
|
||||
if not _is_int(value) or value < 0:
|
||||
raise ValueError(f"{key} — целое >= 0")
|
||||
return True
|
||||
return False
|
||||
22
src/jira_hub/modules/wiki/tick.py
Normal file
22
src/jira_hub/modules/wiki/tick.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Фоновая работа модуля wiki: ежедневная синхронизация проектов с корнем."""
|
||||
from datetime import timedelta
|
||||
|
||||
from ...core import db, settings
|
||||
from . import commands
|
||||
|
||||
|
||||
def auto_sync_tick(hub, conn) -> None:
|
||||
"""Проект с корнем, у которого с последней синхронизации (удачной или нет) прошло больше wiki_auto_sync_hours и нет
|
||||
активного пакета wiki_sync, получает новый."""
|
||||
hours = settings.get(conn, "wiki_auto_sync_hours") or 0
|
||||
if hours <= 0:
|
||||
return
|
||||
for row in conn.execute("SELECT label, root_code, synced_at, failed_at FROM wiki_project ORDER BY label").fetchall():
|
||||
last = max((db.parse(t) for t in (row["synced_at"], row["failed_at"]) if t), default=None)
|
||||
if last is not None and db.utcnow() - last <= timedelta(hours=hours):
|
||||
continue
|
||||
if commands.active_sync(conn, row["label"]) is None:
|
||||
commands.enqueue_sync(conn, row["label"], row["root_code"], "hub")
|
||||
|
||||
|
||||
auto_sync_tick.label = "синхронизация вики"
|
||||
20
tests/fixtures/baseline/schema.expected.json
vendored
20
tests/fixtures/baseline/schema.expected.json
vendored
@ -94,6 +94,26 @@
|
||||
"table",
|
||||
"sync_run",
|
||||
"CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"wiki_attachment",
|
||||
"CREATE TABLE wiki_attachment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n label TEXT NOT NULL,\n page_code TEXT NOT NULL,\n url TEXT NOT NULL,\n name TEXT,\n state TEXT NOT NULL DEFAULT 'none',\n file TEXT,\n size INTEGER,\n error TEXT,\n fetched_at TEXT,\n UNIQUE (label, page_code, url)\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"wiki_page",
|
||||
"CREATE TABLE wiki_page (\n label TEXT NOT NULL,\n code TEXT NOT NULL,\n eva_id TEXT,\n title TEXT,\n parent_code TEXT,\n orderno INTEGER,\n depth INTEGER,\n path TEXT,\n url TEXT,\n size INTEGER,\n synced_at TEXT,\n PRIMARY KEY (label, code)\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"wiki_project",
|
||||
"CREATE TABLE wiki_project (\n label TEXT PRIMARY KEY REFERENCES project(label),\n root_code TEXT NOT NULL,\n root_title TEXT,\n synced_at TEXT,\n failed_at TEXT,\n pages_count INTEGER NOT NULL DEFAULT 0,\n sync_error TEXT\n)"
|
||||
],
|
||||
[
|
||||
"table",
|
||||
"wiki_sync_stage",
|
||||
"CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)"
|
||||
]
|
||||
],
|
||||
"schema_version": 7
|
||||
|
||||
@ -52,7 +52,10 @@
|
||||
"relay_dir": null,
|
||||
"settings_version": 1,
|
||||
"status_labels": {},
|
||||
"transitions_map": {}
|
||||
"transitions_map": {},
|
||||
"wiki_auto_sync_hours": 24,
|
||||
"wiki_min_agent_version": 1,
|
||||
"wiki_pacing": 0.2
|
||||
},
|
||||
"protected": [
|
||||
"schema_version",
|
||||
|
||||
@ -27,7 +27,7 @@ def test_v6_upgrade_keeps_jira_agent(tmp_path):
|
||||
assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok
|
||||
row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone()
|
||||
assert row["agent_version"] == "14"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 # jira и wiki
|
||||
assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией
|
||||
finally:
|
||||
conn.close()
|
||||
@ -95,7 +95,7 @@ def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys):
|
||||
assert cli.main(["tokens"]) == 0
|
||||
lines = capsys.readouterr().out.splitlines()
|
||||
assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ")
|
||||
assert lines[2].startswith("база: ")
|
||||
assert lines[2].startswith("агент wiki: ") and lines[3].startswith("база: ")
|
||||
|
||||
|
||||
def test_agent_view_min_version_from_module_key(conn_two_modules):
|
||||
@ -246,3 +246,17 @@ def test_agents_endpoint(hub_server_two_modules):
|
||||
with pytest.raises(HubError) as e:
|
||||
c.get("/agent", module="nope")
|
||||
assert e.value.status == 404 and e.value.error == "not_found"
|
||||
|
||||
|
||||
def test_wiki_agent_token_after_upgrade(tmp_path):
|
||||
"""Миграция v6: токен jira сохранён, токен wiki создан; строка agent_status есть у обоих."""
|
||||
make_v6_db(tmp_path / "hub.sqlite")
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
try:
|
||||
settings.ensure_defaults(conn)
|
||||
agents = settings.tokens(conn)["agents"]
|
||||
assert agents["jira"] == "T-agent" and agents["wiki"] and agents["wiki"] != agents["jira"]
|
||||
names = {r["agent"] for r in conn.execute("SELECT agent FROM agent_status")}
|
||||
assert names == {"jira", "wiki"}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@ -20,7 +20,7 @@ def test_connect_creates_schema(open_db):
|
||||
assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")}
|
||||
assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")}
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
|
||||
assert "deliveries" in cols
|
||||
|
||||
@ -83,7 +83,7 @@ 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
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
|
||||
conn.close()
|
||||
|
||||
|
||||
@ -280,7 +280,8 @@ def test_migrates_v3_creates_awpca(tmp_path):
|
||||
_v3_database(path)
|
||||
conn = db.connect(path)
|
||||
new = 'project = AURORA AND labels in ("AWPCA")'
|
||||
assert api.project_rows(conn)[0] | {"created_at": None, "issues_count": None} == {
|
||||
assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith("wiki_")} | {
|
||||
"created_at": None, "issues_count": None} == {
|
||||
"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None}
|
||||
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте
|
||||
assert settings.get(conn, "board_id") == 4216
|
||||
|
||||
@ -33,7 +33,8 @@ def test_projects_get_shape(hub_server, add_project):
|
||||
conn.close()
|
||||
got = hub_server["client"].get("/projects")["projects"]
|
||||
assert got == [{"label": "AWPCA", "name": "АРМ КА", "created_at": got[0]["created_at"],
|
||||
"board_id": 4216, "board_type": "scrum", "issues_count": 1}]
|
||||
"board_id": 4216, "board_type": "scrum", "issues_count": 1, "wiki_root": None,
|
||||
"wiki_root_title": None, "wiki_pages": None, "wiki_synced_at": None, "wiki_sync_error": None}]
|
||||
|
||||
|
||||
def test_post_unknown_field(hub_server):
|
||||
|
||||
@ -216,7 +216,7 @@ def test_make_backup_rotates(tmp_path, open_db):
|
||||
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 open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
|
||||
assert open_db(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
|
||||
|
||||
|
||||
def test_rotation_keeps_schema_migration_copy(tmp_path, open_db):
|
||||
|
||||
@ -24,7 +24,7 @@ def _hooks(types):
|
||||
|
||||
|
||||
def test_jira_registered():
|
||||
assert [m.name for m in registry.modules()] == ["jira"]
|
||||
assert [m.name for m in registry.modules()] == ["jira", "wiki"]
|
||||
assert registry.module("jira").name == "jira"
|
||||
assert registry.module("nope") is None
|
||||
|
||||
|
||||
130
tests/test_wiki_commands.py
Normal file
130
tests/test_wiki_commands.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Команды wiki и фоновая синхронизация (wiki-module, spec wiki «Синхронизация вики»)."""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core import db, queue, settings
|
||||
from jira_hub.core.errors import CommandError
|
||||
from jira_hub.modules.jira import refresh
|
||||
from jira_hub.modules.wiki import commands, settings as wiki_settings, tick
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(open_db, add_project):
|
||||
c = open_db()
|
||||
settings.ensure_defaults(c)
|
||||
add_project(c, "AWPCA")
|
||||
return c
|
||||
|
||||
|
||||
def _root(conn, synced_hours=None, failed_hours=None):
|
||||
ago = lambda h: None if h is None else (db.utcnow() - timedelta(hours=h)).isoformat() # noqa: E731
|
||||
conn.execute("INSERT OR REPLACE INTO wiki_project(label, root_code, synced_at, failed_at) VALUES ('AWPCA', 'DOC-1', ?, ?)",
|
||||
(ago(synced_hours), ago(failed_hours)))
|
||||
|
||||
|
||||
def _count(conn):
|
||||
return conn.execute("SELECT COUNT(*) AS n FROM batch WHERE project = 'AWPCA'").fetchone()["n"]
|
||||
|
||||
|
||||
def test_auto_sync_tick(conn):
|
||||
_root(conn, synced_hours=25)
|
||||
tick.auto_sync_tick(None, conn)
|
||||
assert _count(conn) == 1
|
||||
batch = queue.list_batches(conn, project="AWPCA")[0]
|
||||
assert batch["title"] == "Вики AWPCA" and batch["author"] == "hub"
|
||||
tick.auto_sync_tick(None, conn) # пакет в очереди: второй не ставится
|
||||
assert _count(conn) == 1
|
||||
|
||||
|
||||
def test_auto_sync_tick_recent(conn):
|
||||
_root(conn, synced_hours=23)
|
||||
tick.auto_sync_tick(None, conn)
|
||||
assert _count(conn) == 0
|
||||
|
||||
|
||||
def test_auto_sync_tick_failed_resets_clock(conn):
|
||||
_root(conn, synced_hours=30, failed_hours=1) # свежая неудача откладывает следующую попытку
|
||||
tick.auto_sync_tick(None, conn)
|
||||
assert _count(conn) == 0
|
||||
_root(conn, synced_hours=30, failed_hours=26)
|
||||
tick.auto_sync_tick(None, conn)
|
||||
assert _count(conn) == 1
|
||||
|
||||
|
||||
def test_auto_sync_tick_never_synced_without_batch(conn):
|
||||
_root(conn) # корень задан, пакета нет
|
||||
tick.auto_sync_tick(None, conn)
|
||||
assert _count(conn) == 1
|
||||
|
||||
|
||||
def test_auto_sync_tick_disabled(conn):
|
||||
_root(conn, synced_hours=25)
|
||||
settings.set_(conn, "wiki_auto_sync_hours", 0)
|
||||
tick.auto_sync_tick(None, conn)
|
||||
assert _count(conn) == 0
|
||||
|
||||
|
||||
def test_tick_label():
|
||||
assert tick.auto_sync_tick.label == "синхронизация вики"
|
||||
|
||||
|
||||
def test_active_sync(conn):
|
||||
_root(conn, synced_hours=1)
|
||||
assert commands.active_sync(conn, "AWPCA") is None
|
||||
batch = commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u")
|
||||
assert commands.active_sync(conn, "AWPCA")["id"] == batch["id"]
|
||||
queue.cancel_batch(conn, batch["id"])
|
||||
assert commands.active_sync(conn, "AWPCA") is None
|
||||
|
||||
|
||||
def test_auto_refresh_ignores_wiki_batches(conn):
|
||||
_root(conn, synced_hours=1)
|
||||
conn.execute("UPDATE agent_status SET last_seen_at = ? WHERE agent = 'jira'", (db.now(),))
|
||||
commands.enqueue_sync(conn, "AWPCA", "DOC-1", "u")
|
||||
batch = refresh.maybe_auto_refresh(conn)
|
||||
assert batch is not None
|
||||
|
||||
|
||||
def test_check_batch_requires_own_project(conn):
|
||||
cmd = [{"type": "wiki_sync", "params": {"label": "AWPCA", "root": "DOC-1"}}]
|
||||
with pytest.raises(CommandError):
|
||||
queue.create_batch(conn, "t", "u", cmd, project="OTHER")
|
||||
with pytest.raises(CommandError):
|
||||
queue.create_batch(conn, "t", "u", cmd)
|
||||
assert queue.create_batch(conn, "t", "u", cmd, project="AWPCA")["count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [{"label": "AWPCA"}, {"label": "AWPCA", "root": "страница"},
|
||||
{"label": "AWPCA", "root": "DOC-1", "x": 1}])
|
||||
def test_validate_wiki_sync(params):
|
||||
with pytest.raises(CommandError):
|
||||
commands.validate([{"type": "wiki_sync", "params": params}])
|
||||
|
||||
|
||||
def test_validate_wiki_fetch():
|
||||
assert commands.validate([{"type": "wiki_fetch", "params": {"attachment_id": 7, "url": "https://e/f"}}])
|
||||
with pytest.raises(CommandError):
|
||||
commands.validate([{"type": "wiki_fetch", "params": {"attachment_id": "7", "url": "https://e/f"}}])
|
||||
|
||||
|
||||
def test_read_types_have_no_tail_or_pause(conn):
|
||||
assert queue.create_batch(conn, "t", "u", [{"type": "wiki_fetch", "params": {"attachment_id": 1, "url": "u"}}])["count"] == 1
|
||||
assert queue.estimate(conn, ["wiki_sync", "wiki_fetch"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,bad,good", [("wiki_pacing", [-1, 11, "x", True], [0, 0.2, 10]),
|
||||
("wiki_auto_sync_hours", [-1, 1.5, True, "1"], [0, 24]),
|
||||
("wiki_min_agent_version", [-1, 1.5, "1"], [0, 3])])
|
||||
def test_validate_settings(key, bad, good):
|
||||
for v in bad:
|
||||
with pytest.raises(ValueError):
|
||||
settings.validate(key, v)
|
||||
for v in good:
|
||||
settings.validate(key, v)
|
||||
assert wiki_settings.DEFAULTS[key] is not None
|
||||
|
||||
|
||||
def test_wiki_agent_gets_own_keys(conn):
|
||||
out = settings.for_agent(conn, "wiki")
|
||||
assert out == {"wiki_pacing": 0.2, "settings_version": 1}
|
||||
121
tests/test_wiki_projects.py
Normal file
121
tests/test_wiki_projects.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""Корень вики проекта: wiki_root в POST/PATCH /projects, сброс копии (wiki-module, spec wiki «Корень вики проекта»)."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from jira_hub.core.client import HubError
|
||||
from jira_hub.modules.wiki.projects import parse_root
|
||||
|
||||
|
||||
def test_parse_root():
|
||||
assert parse_root("https://eva.example.ru/project/Document/doc-001234") == "DOC-001234"
|
||||
assert parse_root(" DOC-7 ") == "DOC-7" and parse_root(None) is None and parse_root("") is None
|
||||
with pytest.raises(ValueError, match="DOC-"):
|
||||
parse_root("страница про статусы")
|
||||
with pytest.raises(ValueError):
|
||||
parse_root(5)
|
||||
|
||||
|
||||
def _syncs(hub, label=None):
|
||||
"""Пакеты wiki_sync: [(id, статус команды, params)] по порядку."""
|
||||
conn = hub.conn()
|
||||
try:
|
||||
return [(r["batch_id"], r["status"], json.loads(r["params"])) for r in conn.execute(
|
||||
"SELECT batch_id, status, params FROM command WHERE type = 'wiki_sync' ORDER BY id")]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_post_project_with_root(hub_server):
|
||||
out = hub_server["client"].post("/projects", {"label": "S", "name": "S", "wiki_root": "DOC-001234"})
|
||||
assert set(out) == {"project", "batch_id"} and out["project"]["wiki_root"] == "DOC-001234"
|
||||
hub = hub_server["hub"]
|
||||
conn = hub.conn()
|
||||
try:
|
||||
first = conn.execute("SELECT type FROM command WHERE batch_id = ?", (out["batch_id"],)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert first["type"] == "refresh" # batch_id — пакет первого модуля (jira)
|
||||
assert [(s, p) for _, s, p in _syncs(hub)] == [("queued", {"label": "S", "root": "DOC-001234"})]
|
||||
|
||||
|
||||
def test_post_project_without_root_has_no_wiki_batch(hub_server):
|
||||
out = hub_server["client"].post("/projects", {"label": "S", "name": "S"})
|
||||
assert out["project"]["wiki_root"] is None and out["project"]["wiki_pages"] is None
|
||||
assert _syncs(hub_server["hub"]) == []
|
||||
|
||||
|
||||
def test_patch_root_existing(hub_server):
|
||||
c = hub_server["client"]
|
||||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"})
|
||||
out = c.patch("/projects/AWPCA", {"wiki_root": "DOC-000777"})
|
||||
assert len(out["batch_ids"]) == 1 and out["project"]["wiki_root"] == "DOC-000777"
|
||||
assert [p for _, _, p in _syncs(hub_server["hub"])] == [{"label": "AWPCA", "root": "DOC-000777"}]
|
||||
got = {p["label"]: p for p in c.get("/projects")["projects"]}
|
||||
assert got["AWPCA"]["wiki_root"] == "DOC-000777"
|
||||
batch = c.get(f"/batches/{out['batch_ids'][0]}")
|
||||
assert batch["title"] == "Вики AWPCA" and batch["project"] == "AWPCA"
|
||||
|
||||
|
||||
def test_patch_root_bad(hub_server):
|
||||
c = hub_server["client"]
|
||||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"})
|
||||
with pytest.raises(HubError) as e:
|
||||
c.patch("/projects/AWPCA", {"wiki_root": "страница"})
|
||||
assert e.value.status == 422 and "wiki_root" in e.value.detail
|
||||
assert c.get("/projects")["projects"][0]["wiki_root"] == "DOC-1"
|
||||
assert len(_syncs(hub_server["hub"])) == 1
|
||||
|
||||
|
||||
def test_patch_root_cancels_queued(hub_server):
|
||||
c = hub_server["client"]
|
||||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"})
|
||||
c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
|
||||
assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("cancelled", "DOC-12"), ("queued", "DOC-21")]
|
||||
|
||||
|
||||
def test_patch_same_root_does_nothing(hub_server):
|
||||
c = hub_server["client"]
|
||||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"})
|
||||
assert c.patch("/projects/AWPCA", {"wiki_root": "doc-12"})["batch_ids"] == []
|
||||
assert len(_syncs(hub_server["hub"])) == 1
|
||||
|
||||
|
||||
def test_reset_root_removes_copy(hub_server, tmp_path):
|
||||
c = hub_server["client"]
|
||||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1"})
|
||||
c.post("/projects", {"label": "OTHER", "name": "O", "wiki_root": "DOC-2"})
|
||||
copy = tmp_path / "wiki" / "AWPCA" / "АРМ КА"
|
||||
copy.mkdir(parents=True)
|
||||
(copy / "index.md").write_text("# т", encoding="utf-8")
|
||||
other = tmp_path / "wiki" / "OTHER"
|
||||
other.mkdir()
|
||||
conn = hub_server["hub"].conn()
|
||||
try:
|
||||
for label in ("AWPCA", "OTHER"):
|
||||
conn.execute("INSERT INTO wiki_page(label, code, title) VALUES (?, 'DOC-1', 'т')", (label,))
|
||||
conn.execute("INSERT INTO wiki_attachment(label, page_code, url) VALUES (?, 'DOC-1', 'https://e/f')", (label,))
|
||||
finally:
|
||||
conn.close()
|
||||
out = c.patch("/projects/AWPCA", {"wiki_root": None})
|
||||
assert out["batch_ids"] == [] and out["project"]["wiki_root"] is None
|
||||
assert not (tmp_path / "wiki" / "AWPCA").exists() and other.exists()
|
||||
conn = hub_server["hub"].conn()
|
||||
try:
|
||||
for table in ("wiki_project", "wiki_page", "wiki_attachment"):
|
||||
assert [r["label"] for r in conn.execute(f"SELECT label FROM {table}")] == ["OTHER"], table
|
||||
finally:
|
||||
conn.close()
|
||||
assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("cancelled", "DOC-1"), ("queued", "DOC-2")]
|
||||
|
||||
|
||||
def test_claimed_sync_is_not_cancelled_by_new_root(hub_server):
|
||||
c = hub_server["client"]
|
||||
c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-12"})
|
||||
conn = hub_server["hub"].conn()
|
||||
try:
|
||||
conn.execute("UPDATE command SET status = 'claimed' WHERE type = 'wiki_sync'")
|
||||
finally:
|
||||
conn.close()
|
||||
c.patch("/projects/AWPCA", {"wiki_root": "DOC-21"})
|
||||
assert [(s, p["root"]) for _, s, p in _syncs(hub_server["hub"])] == [("claimed", "DOC-12"), ("queued", "DOC-21")]
|
||||
50
uv.lock
generated
50
uv.lock
generated
@ -37,6 +37,19 @@ 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 = "beautifulsoup4"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
@ -269,6 +282,7 @@ name = "jira-hub"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "markdownify" },
|
||||
{ name = "mcp" },
|
||||
]
|
||||
|
||||
@ -278,7 +292,10 @@ dev = [
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "mcp", specifier = ">=1.2,<2" }]
|
||||
requires-dist = [
|
||||
{ name = "markdownify", specifier = ">=1.2.3" },
|
||||
{ name = "mcp", specifier = ">=1.2,<2" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
@ -310,6 +327,19 @@ 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 = "markdownify"
|
||||
version = "1.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/ab/d1297139c0e2ceb151ae564c8c4f57ac0155d8f1f8b4cbd5d6523c82ea36/markdownify-1.2.3.tar.gz", hash = "sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937", size = 18852, upload-time = "2026-06-30T20:27:39.06Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732, upload-time = "2026-06-30T20:27:38.094Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.30.0"
|
||||
@ -645,6 +675,24 @@ wheels = [
|
||||
{ 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 = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "3.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/63/0b6bae54546aed98d72f12d09df57b19a287f01c84d22047fa2e23772ab9/soupsieve-3.0.4.tar.gz", hash = "sha256:924fab84ae21604c19e175e1f39815eb6fa83ba23836e09e9de180925e5bc4c0", size = 119017, upload-time = "2026-10-22T00:45:26.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl", hash = "sha256:8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf", size = 42458, upload-time = "2026-10-22T00:45:24.843Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.5.0"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user