"""Фейковая 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": "", "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