eva-agent v2: версии страниц в wiki_sync (#7) #8
16
src/agent.py
16
src/agent.py
@ -327,12 +327,22 @@ class EvaClient(Http):
|
||||
return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields)
|
||||
|
||||
def list_docs(self, flt: list, fields: List[str]) -> List[dict]:
|
||||
return self.list_all("CmfDocument", flt, fields)
|
||||
|
||||
def list_versions(self, doc_id: str) -> List[dict]:
|
||||
"""Версии страницы (CmfDocumentHistory) по id документа; фильтр parent.code Eva не принимает (-32001)."""
|
||||
return self.list_all("CmfDocumentHistory", [["parent_id", "==", doc_id]], VERSION_FIELDS)
|
||||
|
||||
def get_version(self, code: str) -> Optional[dict]:
|
||||
return self.call("CmfDocumentHistory.get", filter=[["code", "==", code]], fields=VERSION_TEXT_FIELDS)
|
||||
|
||||
def list_all(self, cls: str, flt: list, fields: List[str]) -> List[dict]:
|
||||
"""Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход
|
||||
останавливается на собранном, а не крутится бесконечно (M-4)."""
|
||||
out, offset = [], 0 # type: List[dict], int
|
||||
seen = set() # type: set
|
||||
while True:
|
||||
chunk = self.call("CmfDocument.list", filter=flt, fields=fields,
|
||||
chunk = self.call("%s.list" % cls, filter=flt, fields=fields,
|
||||
slice=[offset, offset + self.PAGE_SIZE]) or []
|
||||
if isinstance(chunk, dict): # обёрнутый результат
|
||||
chunk = chunk.get("items") or chunk.get("result") or []
|
||||
@ -346,7 +356,7 @@ class EvaClient(Http):
|
||||
if len(chunk) < self.PAGE_SIZE:
|
||||
return out
|
||||
if not fresh:
|
||||
log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out))
|
||||
log(" Eva: %s.list повторяет записи (slice не соблюдается?) — останов на %d" % (cls, len(out)))
|
||||
return out
|
||||
offset += self.PAGE_SIZE
|
||||
|
||||
@ -731,6 +741,8 @@ def _parse_at(value) -> float:
|
||||
MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES)
|
||||
MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ
|
||||
TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"]
|
||||
VERSION_FIELDS = ["code", "cmf_created_at", "has_published", "cmf_author"] # список версий — без текста
|
||||
VERSION_TEXT_FIELDS = ["code", "text"]
|
||||
|
||||
|
||||
def _ref_code(ref) -> Optional[str]:
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list) и /files/... на http.server."""
|
||||
"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list, CmfDocumentHistory.get/list) и /files/... на
|
||||
http.server."""
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@ -17,6 +18,10 @@ def _field(doc, path):
|
||||
return cur
|
||||
|
||||
|
||||
# автор версии, как его отдаёт Eva в раскрытом поле cmf_author (проба 28.10.2026)
|
||||
AUTHOR = {"id": "CmfPerson:6c2b", "class_name": "CmfPerson", "login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
|
||||
|
||||
|
||||
class FakeEva:
|
||||
token = "eva-secret"
|
||||
|
||||
@ -29,6 +34,8 @@ class FakeEva:
|
||||
self.requests = [] # {"method", "path", "body", "auth"}
|
||||
self.failures = [] # [status, retry_after] — по одному на ответ
|
||||
self.rpc_errors = []
|
||||
self.method_errors = {} # метод → текст разовой ошибки JSON-RPC
|
||||
self.versions = []
|
||||
self.lock = threading.Lock()
|
||||
eva = self
|
||||
|
||||
@ -87,6 +94,16 @@ class FakeEva:
|
||||
def rpc_error_next(self, text):
|
||||
self.rpc_errors.append(text)
|
||||
|
||||
def rpc_error_on(self, method: str, text: str):
|
||||
"""Разовая ошибка JSON-RPC на первом вызове метода method (очередь rpc_errors не трогает)."""
|
||||
self.method_errors[method] = text
|
||||
|
||||
def add_version(self, parent_id, code, created_at="2026-10-27T17:59:00.123456+03:00", text="", has_published=True,
|
||||
author=AUTHOR):
|
||||
self.versions.append({"id": "CmfDocumentHistory:" + str(code), "parent_id": str(parent_id), "code": code,
|
||||
"cmf_created_at": created_at, "has_published": has_published, "cmf_author": author,
|
||||
"text": text})
|
||||
|
||||
@property
|
||||
def api_calls(self):
|
||||
return [r for r in self.requests if r["path"].startswith("/api/")]
|
||||
@ -109,6 +126,9 @@ class FakeEva:
|
||||
try:
|
||||
if u.path == "/api/" and verb == "POST":
|
||||
body = json.loads(raw)
|
||||
if rpc_error is None:
|
||||
with self.lock:
|
||||
rpc_error = self.method_errors.pop(body.get("method"), None)
|
||||
if rpc_error is not None:
|
||||
out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error}
|
||||
else:
|
||||
@ -140,12 +160,16 @@ class FakeEva:
|
||||
h.wfile.write(body)
|
||||
|
||||
def rpc(self, method, kw):
|
||||
found = [d for d in self.docs if self._match(d, kw.get("filter") or [])]
|
||||
fields = kw.get("fields")
|
||||
pick = lambda d: self._out(d, fields) # noqa: E731
|
||||
if method == "CmfDocument.get":
|
||||
if method.startswith("CmfDocumentHistory."):
|
||||
found = [v for v in self.versions if self._match(v, kw.get("filter") or [])]
|
||||
pick = lambda v: {k: v.get(k) for k in fields} if fields else dict(v) # noqa: E731
|
||||
else:
|
||||
found = [d for d in self.docs if self._match(d, kw.get("filter") or [])]
|
||||
pick = lambda d: self._out(d, fields) # noqa: E731
|
||||
if method in ("CmfDocument.get", "CmfDocumentHistory.get"):
|
||||
return pick(found[0]) if found else None
|
||||
if method == "CmfDocument.list":
|
||||
if method in ("CmfDocument.list", "CmfDocumentHistory.list"):
|
||||
lo, hi = kw.get("slice") or [0, len(found)]
|
||||
return [pick(d) for d in found[lo:hi]]
|
||||
raise ValueError("метод %s не поддержан" % method)
|
||||
|
||||
@ -3,6 +3,7 @@ import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
from tests import _common as c
|
||||
from tests import fake_eva
|
||||
from tests.fake_eva import FakeEva
|
||||
|
||||
agent = c.agent
|
||||
@ -86,6 +87,7 @@ class EvaClientTest(unittest.TestCase):
|
||||
self.assertEqual(len(docs), 450)
|
||||
self.assertEqual(len(self.eva.api_calls), 2)
|
||||
self.assertIn("slice не соблюдается", out.getvalue())
|
||||
self.assertIn("CmfDocument.list повторяет записи", out.getvalue())
|
||||
|
||||
def test_list_slice_ignored_same_page(self):
|
||||
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
|
||||
@ -184,6 +186,57 @@ class EvaClientTest(unittest.TestCase):
|
||||
self.assertNotIn("eva-secret", t)
|
||||
self.assertNotIn("eva-secret", repr(self.cli))
|
||||
|
||||
def test_list_versions_filter_and_fields(self):
|
||||
self.eva.add_version("CmfDocument:1", "DOCH-1")
|
||||
self.eva.add_version("CmfDocument:1", "DOCH-2", has_published=False)
|
||||
self.eva.add_version("CmfDocument:2", "DOCH-9")
|
||||
got = self.cli.list_versions("CmfDocument:1")
|
||||
self.assertEqual([v["code"] for v in got], ["DOCH-1", "DOCH-2"])
|
||||
self.assertEqual(got[0], {"code": "DOCH-1", "cmf_created_at": "2026-10-27T17:59:00.123456+03:00",
|
||||
"has_published": True, "cmf_author": fake_eva.AUTHOR})
|
||||
req = self.eva.api_calls[0]
|
||||
self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.list")
|
||||
self.assertEqual(req["body"]["kwargs"]["filter"], [["parent_id", "==", "CmfDocument:1"]])
|
||||
self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "cmf_created_at", "has_published", "cmf_author"])
|
||||
self.assertEqual(req["body"]["kwargs"]["slice"], [0, 200])
|
||||
|
||||
def test_list_versions_pages_200(self):
|
||||
for i in range(450):
|
||||
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
|
||||
self.assertEqual(len(self.cli.list_versions("CmfDocument:1")), 450)
|
||||
self.assertEqual([r["body"]["kwargs"]["slice"] for r in self.eva.api_calls], [[0, 200], [200, 400], [400, 600]])
|
||||
|
||||
def test_list_versions_slice_ignored(self):
|
||||
orig = self.eva.rpc
|
||||
self.eva.rpc = lambda m, kw: orig(m, dict(kw, slice=None))
|
||||
for i in range(450):
|
||||
self.eva.add_version("CmfDocument:1", "DOCH-%d" % i)
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
got = self.cli.list_versions("CmfDocument:1")
|
||||
self.assertEqual(len(got), 450)
|
||||
self.assertEqual(len(self.eva.api_calls), 2)
|
||||
self.assertIn("CmfDocumentHistory.list повторяет записи", out.getvalue())
|
||||
|
||||
def test_get_version(self):
|
||||
self.eva.add_version("CmfDocument:1", "DOCH-1", text="<p>v1</p>")
|
||||
self.assertEqual(self.cli.get_version("DOCH-1"), {"code": "DOCH-1", "text": "<p>v1</p>"})
|
||||
self.assertIsNone(self.cli.get_version("DOCH-404"))
|
||||
req = self.eva.api_calls[0]
|
||||
self.assertEqual(req["path"], "/api/?m=CmfDocumentHistory.get")
|
||||
self.assertEqual(req["body"]["kwargs"]["filter"], [["code", "==", "DOCH-1"]])
|
||||
self.assertEqual(req["body"]["kwargs"]["fields"], ["code", "text"])
|
||||
self.assertEqual(self.cli.calls, 2)
|
||||
|
||||
def test_rpc_error_on_method(self):
|
||||
self.eva.add_doc(1, "DOC-1", project="P")
|
||||
self.eva.rpc_error_on("CmfDocumentHistory.list", "нет прав")
|
||||
self.assertIsNotNone(self.cli.get_doc("DOC-1", ["code"]))
|
||||
with self.assertRaises(agent.EvaError) as cm:
|
||||
self.cli.list_versions("1")
|
||||
self.assertEqual(str(cm.exception), "CmfDocumentHistory.list: нет прав")
|
||||
self.assertEqual(self.cli.list_versions("1"), []) # ошибка разовая
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user