eva-agent/tests/fake_eva.py
Andrey Epifancev 3597495a6a Команда wiki_fetch (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:17:49 +04:00

159 lines
6.9 KiB
Python

"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.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
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.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)
@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 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):
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":
return pick(found[0]) if found else None
if method == "CmfDocument.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