Команда wiki_fetch (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
50878d5965
commit
3597495a6a
@ -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`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой.
|
||||
|
||||
29
src/agent.py
29
src/agent.py
@ -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")})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()])
|
||||
|
||||
|
||||
@ -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
93
tests/test_wiki_fetch.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user