eva-agent/tests/fake_eva.py
2026-10-28 11:46:11 +04:00

183 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list, CmfDocumentHistory.get/list) и /files/... на
http.server."""
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
def _field(doc, path):
"""Значение поля по пути `project.code`: ссылка project хранится как {"id", "code"}, parent/tree_parent — кодом."""
if path in ("parent.code", "tree_parent.code"):
return doc.get(path.split(".")[0])
cur = doc
for part in path.split("."):
if not isinstance(cur, dict):
return None
cur = cur.get(part)
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"
def __init__(self):
self.docs = []
# форма ссылок 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: True/False/заявленное число)
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
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
eva.handle(self, "GET")
def do_POST(self):
eva.handle(self, "POST")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
self.server.server_close()
def add_doc(self, id, code, name="", parent=None, tree_parent=None, project=None, orderno=None, text=""):
# parent/tree_parent хранятся кодами родителей; в ответе они превращаются в ссылку по self.ref_form
ref = lambda c: {"id": c, "code": c} if c is not None else None # noqa: E731
self.docs.append({"id": id, "code": code, "name": name, "parent": parent,
"tree_parent": tree_parent, "project": ref(project), "orderno": orderno,
"text": text})
def _ref(self, code):
if code is None:
return None
target = next((d for d in self.docs if d["code"] == code), None)
rid = str(target["id"]) if target else code
form = self.ref_form
if form == "id_only":
return {"id": rid}
if form == "string":
return rid
if form == "code_id":
return {"id": code, "code": code}
return {"id": rid, "code": code}
def _out(self, d, fields):
d = dict(d, parent=self._ref(d["parent"]), tree_parent=self._ref(d["tree_parent"]))
return {k: d.get(k) for k in fields} if fields else d
def add_file(self, path, data, length=True):
self.files[path] = (data, length)
def fail_next(self, status, times=1, retry_after=None):
self.failures.extend([[status, retry_after]] * times)
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/")]
def handle(self, h, verb):
u = urlsplit(h.path)
n = int(h.headers.get("Content-Length") or 0)
raw = h.rfile.read(n) if n else b""
auth = h.headers.get("Authorization")
with self.lock:
self.requests.append({"method": verb, "path": h.path,
"body": json.loads(raw) if raw else None, "auth": auth})
failure = self.failures.pop(0) if self.failures else None
rpc_error = self.rpc_errors.pop(0) if self.rpc_errors else None
if auth != "Bearer " + self.token:
return self._reply(h, 401, b'{"error": "unauthorized"}')
if failure:
extra = {"Retry-After": str(failure[1])} if failure[1] is not None else {}
return self._reply(h, failure[0], b'{"error": "busy"}', extra)
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:
out = {"jsonrpc": "2.2", "callid": body.get("callid"),
"result": self.rpc(body["method"], body.get("kwargs") or {})}
return self._reply(h, 200, json.dumps(out).encode("utf-8"))
if u.path.startswith("/files/") and verb == "GET":
if u.path not in self.files:
return self._reply(h, 404, b'{"error": "not found"}')
data, with_length = self.files[u.path]
h.send_response(200)
h.send_header("Content-Type", "application/octet-stream")
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
self._reply(h, 404, b"{}")
except (BrokenPipeError, ConnectionResetError):
pass
def _reply(self, h, status, body, headers=None):
h.send_response(status)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(body)))
for k, v in (headers or {}).items():
h.send_header(k, v)
h.end_headers()
h.wfile.write(body)
def rpc(self, method, kw):
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 in ("CmfDocument.get", "CmfDocumentHistory.get"):
return pick(found[0]) if found else None
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)
@staticmethod
def _match(doc, flt):
for field, op, value in flt:
if op != "==" or _field(doc, field) != value:
return False
return True