eva-agent/tests/fake_eva.py
Andrey Epifancev 7bc59f3f32 Клиент Eva JSON-RPC (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:49:50 +04:00

135 lines
5.5 KiB
Python
Raw 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) и /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`: ссылка хранится как {"id", "code"}."""
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 = []
self.files = {} # путь → (байты, с Content-Length?)
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=""):
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": ref(parent),
"tree_parent": ref(tree_parent), "project": ref(project), "orderno": orderno,
"text": text})
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:
h.send_header("Content-Length", str(len(data)))
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: {k: d.get(k) for k in fields}) if fields else dict # 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