EvaClient: общий список по классу и версии страниц CmfDocumentHistory (#7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
11e39b348d
commit
4bea83cdae
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)
|
return self.call("CmfDocument.get", filter=[["code", "==", code]], fields=fields)
|
||||||
|
|
||||||
def list_docs(self, flt: list, fields: List[str]) -> List[dict]:
|
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 (ответ без новых записей), обход
|
"""Весь список страницами по PAGE_SIZE. Если Eva не соблюдает slice (ответ без новых записей), обход
|
||||||
останавливается на собранном, а не крутится бесконечно (M-4)."""
|
останавливается на собранном, а не крутится бесконечно (M-4)."""
|
||||||
out, offset = [], 0 # type: List[dict], int
|
out, offset = [], 0 # type: List[dict], int
|
||||||
seen = set() # type: set
|
seen = set() # type: set
|
||||||
while True:
|
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 []
|
slice=[offset, offset + self.PAGE_SIZE]) or []
|
||||||
if isinstance(chunk, dict): # обёрнутый результат
|
if isinstance(chunk, dict): # обёрнутый результат
|
||||||
chunk = chunk.get("items") or chunk.get("result") or []
|
chunk = chunk.get("items") or chunk.get("result") or []
|
||||||
@ -346,7 +356,7 @@ class EvaClient(Http):
|
|||||||
if len(chunk) < self.PAGE_SIZE:
|
if len(chunk) < self.PAGE_SIZE:
|
||||||
return out
|
return out
|
||||||
if not fresh:
|
if not fresh:
|
||||||
log(" Eva: CmfDocument.list повторяет записи (slice не соблюдается?) — останов на %d" % len(out))
|
log(" Eva: %s.list повторяет записи (slice не соблюдается?) — останов на %d" % (cls, len(out)))
|
||||||
return out
|
return out
|
||||||
offset += self.PAGE_SIZE
|
offset += self.PAGE_SIZE
|
||||||
|
|
||||||
@ -731,6 +741,8 @@ def _parse_at(value) -> float:
|
|||||||
MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES)
|
MAX_PAGES = 50 # страниц в пачке (предел хаба, stage.MAX_PAGES)
|
||||||
MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ
|
MAX_BATCH_BYTES = 4 * 1024 * 1024 # JSON пачки; предел тела хаба — 16 МБ
|
||||||
TREE_FIELDS = ["id", "code", "name", "parent", "tree_parent", "project", "orderno"]
|
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]:
|
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 json
|
||||||
import threading
|
import threading
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@ -17,6 +18,10 @@ def _field(doc, path):
|
|||||||
return cur
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
# автор версии, как его отдаёт Eva в раскрытом поле cmf_author (проба 28.10.2026)
|
||||||
|
AUTHOR = {"id": "CmfPerson:6c2b", "class_name": "CmfPerson", "login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
|
||||||
|
|
||||||
|
|
||||||
class FakeEva:
|
class FakeEva:
|
||||||
token = "eva-secret"
|
token = "eva-secret"
|
||||||
|
|
||||||
@ -29,6 +34,8 @@ class FakeEva:
|
|||||||
self.requests = [] # {"method", "path", "body", "auth"}
|
self.requests = [] # {"method", "path", "body", "auth"}
|
||||||
self.failures = [] # [status, retry_after] — по одному на ответ
|
self.failures = [] # [status, retry_after] — по одному на ответ
|
||||||
self.rpc_errors = []
|
self.rpc_errors = []
|
||||||
|
self.method_errors = {} # метод → текст разовой ошибки JSON-RPC
|
||||||
|
self.versions = []
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
eva = self
|
eva = self
|
||||||
|
|
||||||
@ -87,6 +94,16 @@ class FakeEva:
|
|||||||
def rpc_error_next(self, text):
|
def rpc_error_next(self, text):
|
||||||
self.rpc_errors.append(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
|
@property
|
||||||
def api_calls(self):
|
def api_calls(self):
|
||||||
return [r for r in self.requests if r["path"].startswith("/api/")]
|
return [r for r in self.requests if r["path"].startswith("/api/")]
|
||||||
@ -109,6 +126,9 @@ class FakeEva:
|
|||||||
try:
|
try:
|
||||||
if u.path == "/api/" and verb == "POST":
|
if u.path == "/api/" and verb == "POST":
|
||||||
body = json.loads(raw)
|
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:
|
if rpc_error is not None:
|
||||||
out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error}
|
out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error}
|
||||||
else:
|
else:
|
||||||
@ -140,12 +160,16 @@ class FakeEva:
|
|||||||
h.wfile.write(body)
|
h.wfile.write(body)
|
||||||
|
|
||||||
def rpc(self, method, kw):
|
def rpc(self, method, kw):
|
||||||
found = [d for d in self.docs if self._match(d, kw.get("filter") or [])]
|
|
||||||
fields = kw.get("fields")
|
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
|
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
|
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)]
|
lo, hi = kw.get("slice") or [0, len(found)]
|
||||||
return [pick(d) for d in found[lo:hi]]
|
return [pick(d) for d in found[lo:hi]]
|
||||||
raise ValueError("метод %s не поддержан" % method)
|
raise ValueError("метод %s не поддержан" % method)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import unittest
|
|||||||
from contextlib import redirect_stderr, redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
|
||||||
from tests import _common as c
|
from tests import _common as c
|
||||||
|
from tests import fake_eva
|
||||||
from tests.fake_eva import FakeEva
|
from tests.fake_eva import FakeEva
|
||||||
|
|
||||||
agent = c.agent
|
agent = c.agent
|
||||||
@ -86,6 +87,7 @@ class EvaClientTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(docs), 450)
|
self.assertEqual(len(docs), 450)
|
||||||
self.assertEqual(len(self.eva.api_calls), 2)
|
self.assertEqual(len(self.eva.api_calls), 2)
|
||||||
self.assertIn("slice не соблюдается", out.getvalue())
|
self.assertIn("slice не соблюдается", out.getvalue())
|
||||||
|
self.assertIn("CmfDocument.list повторяет записи", out.getvalue())
|
||||||
|
|
||||||
def test_list_slice_ignored_same_page(self):
|
def test_list_slice_ignored_same_page(self):
|
||||||
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
|
"""M-4: Eva всегда отдаёт первые 200 — без защиты цикл бесконечен."""
|
||||||
@ -184,6 +186,57 @@ class EvaClientTest(unittest.TestCase):
|
|||||||
self.assertNotIn("eva-secret", t)
|
self.assertNotIn("eva-secret", t)
|
||||||
self.assertNotIn("eva-secret", repr(self.cli))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user