forked from jason/echo
136 lines
5.0 KiB
Python
136 lines
5.0 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""mock_olrapi_hifi.py — H4: higher-fidelity mock of the Obsidian Local REST API.
|
||
|
|
|
||
|
|
The shipped mock's PATCH is a "naive append": it appends the body to EOF instead of
|
||
|
|
inserting/replacing under the target heading, so the 0.9 surface (capture's
|
||
|
|
_append_to_existing, scope set's replace, the daily Agent-Log insert) is only ever
|
||
|
|
validated against behavior the real API does NOT exhibit. This subclass models the
|
||
|
|
real PATCH semantics so the eval can catch heading-insert / replace regressions.
|
||
|
|
|
||
|
|
Over the base mock (eval/mock_olrapi.py) this overrides do_PATCH only:
|
||
|
|
* heading append -> insert at the END of the target section's body (before the next
|
||
|
|
sibling/parent heading), NOT at EOF.
|
||
|
|
* heading prepend -> insert at the START of the section body (right after the heading).
|
||
|
|
* heading replace -> replace the section body, keep the heading line.
|
||
|
|
* heading missing -> 400 errorCode 40080 (the silent-write-loss trigger). [unchanged]
|
||
|
|
* frontmatter replace -> parse the YAML block and set/replace the scalar; a replace on a
|
||
|
|
MISSING field returns 400 40080 (matches the real API, per SKILL.md).
|
||
|
|
|
||
|
|
Everything else (GET/PUT/POST/DELETE, directory listing, document-map, flaky/phantom
|
||
|
|
fault injection, debug reset) is inherited unchanged from the base handler.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
from http.server import ThreadingHTTPServer
|
||
|
|
|
||
|
|
import mock_olrapi
|
||
|
|
from mock_olrapi import H as BaseH, STATE
|
||
|
|
|
||
|
|
|
||
|
|
def _heading(line: str):
|
||
|
|
m = re.match(r"^(#{1,6})\s+(.*)$", line)
|
||
|
|
return (len(m.group(1)), m.group(2).strip()) if m else (0, None)
|
||
|
|
|
||
|
|
|
||
|
|
def section_span(lines, target):
|
||
|
|
"""(start, end, level) for the section under `target` (an "A::B" path; matched on its
|
||
|
|
leaf). Body lines are lines[start+1:end]. None if the heading is absent."""
|
||
|
|
leaf = target.split("::")[-1].strip()
|
||
|
|
start = level = None
|
||
|
|
for i, line in enumerate(lines):
|
||
|
|
lv, txt = _heading(line)
|
||
|
|
if lv and txt == leaf:
|
||
|
|
start, level = i, lv
|
||
|
|
break
|
||
|
|
if start is None:
|
||
|
|
return None
|
||
|
|
end = len(lines)
|
||
|
|
for j in range(start + 1, len(lines)):
|
||
|
|
lv, _ = _heading(lines[j])
|
||
|
|
if lv and lv <= level:
|
||
|
|
end = j
|
||
|
|
break
|
||
|
|
return start, end, level
|
||
|
|
|
||
|
|
|
||
|
|
def set_frontmatter(text: str, field: str, raw_value: str):
|
||
|
|
"""Return (new_text, existed). raw_value is the JSON the client PATCHed."""
|
||
|
|
try:
|
||
|
|
value = json.loads(raw_value)
|
||
|
|
except Exception:
|
||
|
|
value = raw_value.strip().strip('"')
|
||
|
|
rendered = value if isinstance(value, str) else json.dumps(value)
|
||
|
|
new_line = f"{field}: {rendered}"
|
||
|
|
if not text.startswith("---"):
|
||
|
|
return f"---\n{new_line}\n---\n\n{text}", False
|
||
|
|
end = text.find("\n---", 3)
|
||
|
|
head = text[4:end] if end != -1 else text
|
||
|
|
rest = text[end:] if end != -1 else "\n---\n"
|
||
|
|
out, existed = [], False
|
||
|
|
for line in head.splitlines():
|
||
|
|
if re.match(rf"^{re.escape(field)}\s*:", line):
|
||
|
|
out.append(new_line)
|
||
|
|
existed = True
|
||
|
|
else:
|
||
|
|
out.append(line)
|
||
|
|
if not existed:
|
||
|
|
out.append(new_line)
|
||
|
|
return "---\n" + "\n".join(out) + rest, existed
|
||
|
|
|
||
|
|
|
||
|
|
class H(BaseH):
|
||
|
|
def do_PATCH(self):
|
||
|
|
vp = self._vpath()
|
||
|
|
body = self._read_body()
|
||
|
|
if vp is None:
|
||
|
|
return self._json(400, {"message": "bad path"})
|
||
|
|
ttype = self.headers.get("Target-Type", "")
|
||
|
|
op = self.headers.get("Operation", "append")
|
||
|
|
target = self.headers.get("Target", "")
|
||
|
|
cur = STATE.get(vp, "")
|
||
|
|
|
||
|
|
if ttype == "heading":
|
||
|
|
lines = cur.split("\n")
|
||
|
|
span = section_span(lines, target)
|
||
|
|
if span is None:
|
||
|
|
return self._json(400, {"errorCode": 40080, "message": "invalid-target"})
|
||
|
|
start, end, _ = span
|
||
|
|
payload = body.strip("\n").split("\n") if body.strip("\n") else [""]
|
||
|
|
if op == "replace":
|
||
|
|
lines[start + 1:end] = payload
|
||
|
|
elif op == "prepend":
|
||
|
|
lines[start + 1:start + 1] = payload
|
||
|
|
else: # append at the tail of the section body (before the next heading)
|
||
|
|
ins = end
|
||
|
|
while ins > start + 1 and lines[ins - 1].strip() == "":
|
||
|
|
ins -= 1
|
||
|
|
lines[ins:ins] = payload
|
||
|
|
STATE[vp] = "\n".join(lines)
|
||
|
|
return self._send(200, "")
|
||
|
|
|
||
|
|
if ttype == "frontmatter":
|
||
|
|
new, existed = set_frontmatter(cur, target, body)
|
||
|
|
if op == "replace" and not existed:
|
||
|
|
return self._json(400, {"errorCode": 40080, "message": "invalid-target"})
|
||
|
|
STATE[vp] = new
|
||
|
|
return self._send(200, "")
|
||
|
|
|
||
|
|
STATE[vp] = cur + "\n" + body
|
||
|
|
return self._send(200, "")
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
ap = argparse.ArgumentParser()
|
||
|
|
ap.add_argument("--port", type=int, default=8800)
|
||
|
|
a = ap.parse_args()
|
||
|
|
srv = ThreadingHTTPServer(("127.0.0.1", a.port), H)
|
||
|
|
print(f"mock-olrapi-hifi listening on http://127.0.0.1:{a.port}", flush=True)
|
||
|
|
srv.serve_forever()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|