Команда wiki_fetch (#1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 05:17:49 +04:00
parent 50878d5965
commit 3597495a6a
7 changed files with 137 additions and 7 deletions

View File

@ -184,3 +184,12 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге
- `FakeEva`: id документов отличны от кодов; ссылки по умолчанию `{"id": "<id документа>", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`.
Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена).
32. **`wiki_fetch` (задача 5): уточнения.**
- Проверка `Content-Length` вынесена в `Http.fetch_bytes`: заявленная длина больше предела — `TooBig` до чтения тела; без заголовка работает потоковое чтение limit + 1 байт. Предел включительно: ровно 10 МБ принимается.
- Ошибки: чужой хост (`ValueError` из `download`) → «чужой хост»; `TooBig` → «файл больше 10 МБ — откройте в Eva» (как в спеке хаба); HTTP 404 → «файл не найден в Eva»; перенаправление на другой адрес → «Eva перенаправила скачивание на <хост>»; прочее — общие правила `Executor.run` (5xx и сеть повторяются).
- Пустой файл (0 байт): хаб принимает его от агента (`POST /agent/files` → `files.put(allow_empty=True)`, решение 13 хаба; `check_result` сверяет размер 0 с `files.size`, `None` — только «нет файла»), поэтому агент отчитывается `done {file, size: 0}` без особых случаев. Расхождения с брифом нет; `fake_hub` повторяет это и добавил 413 для файла > 10 МБ, как `files.put`.
- Результат `{attachment_id, file, size}`; `size` берётся из ответа хаба. Содержимое в лог не пишется (только размер); в памяти одна копия данных, base64 строится на время отправки.
- Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva).
Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой.

View File

@ -113,6 +113,9 @@ class RedirectAway(Exception):
self.host = host
MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт)
class TooBig(Exception):
"""Ответ длиннее предела: чтение оборвано."""
@ -201,11 +204,13 @@ class Http:
req.add_header("Accept", "*/*")
try:
with self._opener().open(req, timeout=timeout or self.timeout) as resp:
if resp.length is not None and resp.length > limit:
raise TooBig("ответ длиннее %d байт (Content-Length %d)" % (limit, resp.length)) # тело не читаем
data = resp.read(limit + 1)
rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше
if rest and len(data) <= limit:
raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest))
except RedirectAway:
except (RedirectAway, TooBig):
self._touch()
raise
except urllib.error.HTTPError as e:
@ -835,7 +840,27 @@ class Executor:
return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls})
def do_wiki_fetch(self, p: dict, cmd: dict) -> dict:
return self._out("failed", error="wiki_fetch: не реализовано")
"""Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24);
содержимое в лог не попадает, в памяти одна копия (плюс base64 на время отправки)."""
url = str(p.get("url") or "").strip()
if not url:
return self._out("failed", error="wiki_fetch: не задан url")
try:
data = self.eva.download(url, MAX_ATTACHMENT)
except ValueError:
return self._out("failed", error="чужой хост")
except TooBig:
return self._out("failed", error="файл больше 10 МБ — откройте в Eva")
except HttpError as e:
if e.code == 404:
return self._out("failed", error="файл не найден в Eva")
raise
except RedirectAway as e:
return self._out("failed", error="Eva перенаправила скачивание на %s" % e.host)
log(" вложение #%s (%d байт) <- Eva" % (p.get("attachment_id"), len(data)))
got = self._hub(self.hub.upload_file, data)
del data
return self._out("done", {"attachment_id": p.get("attachment_id"), "file": got.get("file"), "size": got.get("size")})
# ---------------------------------------------------------------------------

View File

@ -25,7 +25,7 @@ class FakeEva:
# форма ссылок parent/tree_parent в ответах: "full" — {"id": "<id документа>", "code"} (по умолчанию, как в Eva),
# "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка)
self.ref_form = "full"
self.files = {} # путь → (байты, с Content-Length?)
self.files = {} # путь → (байты, Content-Length: True/False/заявленное число)
self.requests = [] # {"method", "path", "body", "auth"}
self.failures = [] # [status, retry_after] — по одному на ответ
self.rpc_errors = []
@ -121,8 +121,8 @@ class FakeEva:
data, with_length = self.files[u.path]
h.send_response(200)
h.send_header("Content-Type", "application/octet-stream")
if with_length:
h.send_header("Content-Length", str(len(data)))
if with_length: # True — настоящая длина; число — заявленная (ложь, для проверки заголовка)
h.send_header("Content-Length", str(len(data) if with_length is True else with_length))
h.end_headers() # без длины HTTP/1.0 отдаёт тело до закрытия соединения
h.wfile.write(data)
return

View File

@ -10,6 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MAX_PAGES = 50
MAX_FILE = 10 * 1024 * 1024
def check_pages(command, command_id, pages):
@ -185,6 +186,8 @@ class FakeHub:
if self.reject_files:
return self.reject_files
data = base64.b64decode(body.get("content") or "", validate=True)
if len(data) > MAX_FILE: # как files.put хаба; пустой файл от агента принимается (allow_empty)
return 413, {"error": "too_large", "detail": "файл больше %d байт (%d)" % (MAX_FILE, len(data))}
self.uploaded.append(data)
return 200, {"file": self.put_file(data), "size": len(data)}
m = re.match(r"^/agent/commands/(\d+)$", path)

View File

@ -210,7 +210,7 @@ class FolderIntegrationTest(unittest.TestCase):
runner.idle_limit = 1
runner.loop()
self.assertEqual(self.hub.results[cid]["status"], "failed") # заглушка Executor
self.assertEqual(self.hub.results[cid]["error"], "wiki_fetch: не реализовано")
self.assertEqual(self.hub.results[cid]["error"], "чужой хост")
self.assertEqual(self.hub.results[cid]["agent_version"], agent.AGENT_VERSION)
self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()])

View File

@ -12,7 +12,7 @@ from fake_hub import FakeHub
FETCH = {"url": "https://eva.example.ru/files/a.png", "name": "a.png"}
STUB_ERROR = "wiki_fetch: не реализовано" # Executor каркаса: обе команды — failed
STUB_ERROR = "чужой хост" # FETCH указывает на чужой хост: wiki_fetch отказывает без запросов к Eva
class RunnerTest(unittest.TestCase):

93
tests/test_wiki_fetch.py Normal file
View File

@ -0,0 +1,93 @@
import hashlib
import os
import unittest
from _common import SETTINGS, agent, eva_cfg, no_sleep
from fake_eva import FakeEva
from fake_hub import FakeHub
MB = 1024 * 1024
TOO_BIG = "файл больше 10 МБ — откройте в Eva"
class WikiFetchTest(unittest.TestCase):
def setUp(self):
self.eva = FakeEva()
self.hub = FakeHub(SETTINGS)
self.addCleanup(self.eva.stop)
self.addCleanup(self.hub.stop)
cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token)
client = agent.EvaClient(cfg)
client.sleep = no_sleep
self.ex = agent.Executor(client, agent.HubClient(cfg), dict(SETTINGS))
def fetch(self, url):
return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}})
def file_url(self, path="/files/a.bin"):
return self.eva.base_url + path
def test_fetch_ok(self):
data = os.urandom(300 * 1024)
self.eva.add_file("/files/a.bin", data)
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 307200)
self.assertEqual(out["result"]["file"], hashlib.sha256(data).hexdigest())
self.assertEqual(self.hub.files[out["result"]["file"]], data)
self.assertEqual(self.eva.requests[0]["auth"], "Bearer " + self.eva.token)
def test_fetch_foreign_host(self):
out = self.fetch("http://127.0.0.1:1/files/a.bin")
self.assertEqual(out["status"], "failed")
self.assertIn("чужой хост", out["error"])
self.assertEqual(self.eva.requests, [])
self.assertEqual(self.hub.uploaded, [])
def test_fetch_too_big_header(self):
self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], TOO_BIG)
self.assertEqual(self.hub.uploaded, [])
def test_fetch_too_big_streaming(self):
self.eva.add_file("/files/a.bin", b"x" * (10 * MB + 1), length=False)
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], TOO_BIG)
self.assertEqual(self.hub.uploaded, [])
def test_fetch_exactly_limit(self):
self.eva.add_file("/files/a.bin", b"x" * (10 * MB))
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 10 * MB)
def test_fetch_404(self):
out = self.fetch(self.file_url("/files/none.bin"))
self.assertEqual(out["status"], "failed")
self.assertEqual(out["error"], "файл не найден в Eva")
def test_fetch_empty_file(self):
self.eva.add_file("/files/a.bin", b"")
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["size"], 0)
self.assertEqual(self.hub.files[out["result"]["file"]], b"")
def test_fetch_hub_error_is_hub_error(self):
self.eva.add_file("/files/a.bin", b"abc")
self.hub.reject_files = (500, {"error": "boom"})
out = self.fetch(self.file_url())
self.assertEqual(out["status"], "failed")
self.assertTrue(out["error"].startswith("хаб:"), out)
self.assertTrue(out["retryable"])
def test_fetch_no_url(self):
out = self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7}})
self.assertEqual(out["status"], "failed")
if __name__ == "__main__":
unittest.main()