EvaClient: общий список по классу и версии страниц CmfDocumentHistory (#7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 11:46:11 +04:00
parent 11e39b348d
commit 4bea83cdae
3 changed files with 96 additions and 7 deletions

View File

@ -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]:

View File

@ -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")
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 == "CmfDocument.get":
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)

View File

@ -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()