mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:47:20 +01:00
Standalone utils subproject (pyproject, pre-commit, CI, install.sh, README with the library API). It no longer imports leetcode_guard: guard_bridge resolves the three read-only calls on demand. Q5: SQL problems skip req: and ask for an OUTPUT SHAPE (columns, rows, grouping) before code; algorithm problems keep req:. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
226 lines
6.8 KiB
Python
226 lines
6.8 KiB
Python
"""Shared plumbing for the relay tests: a fake claude, a fake probe, a client.
|
|
|
|
The relay runs in-process with its real threads (coverage sees it), on port
|
|
0, with the 3 s pace and the minute between probes injected away. The
|
|
LeetCode guard's default data dir sits under a redirected HOME and is seeded
|
|
(`guard_digest`), so "byte-identical afterwards" is checked on files the live
|
|
harvester cannot touch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import hashlib
|
|
from http.client import HTTPConnection
|
|
import itertools
|
|
import json
|
|
from pathlib import Path
|
|
import time
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from leetcode_coach.relay.app import Relay
|
|
from leetcode_coach.relay.coach import Deps
|
|
from leetcode_coach.relay.probe import AcceptedProbe
|
|
from leetcode_coach.tests._relay_statements import MISSING_NUMBER
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
FAKE_CLAUDE = Path(__file__).with_name("_fake_claude.py")
|
|
DEV = "3f2a7c1e-9b4d-4e6f-8a1b-2c3d4e5f6a7b"
|
|
_RIDS = itertools.count(1)
|
|
|
|
|
|
def rid() -> str:
|
|
"""A fresh ULID-shaped request id."""
|
|
return f"01JAXY{next(_RIDS):020d}"
|
|
|
|
|
|
def fake_claude(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, **script: list[dict[str, Any]]
|
|
) -> Path:
|
|
"""Point ``LEETCODE_CLAUDE_BIN`` at the scripted fake; its directory."""
|
|
root = tmp_path / "fake-claude"
|
|
root.mkdir(exist_ok=True)
|
|
(root / "calls.jsonl").write_text("", encoding="utf-8")
|
|
(root / "script.json").write_text(json.dumps(script), encoding="utf-8")
|
|
monkeypatch.setenv("LEETCODE_CLAUDE_BIN", str(FAKE_CLAUDE))
|
|
monkeypatch.setenv("FAKE_CLAUDE_DIR", str(root))
|
|
return root
|
|
|
|
|
|
def calls(root: Path) -> list[dict[str, Any]]:
|
|
"""Every call the fake claude answered."""
|
|
lines = (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()
|
|
return [json.loads(line) for line in lines]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FakeSubmission:
|
|
"""One accepted submission, as the probe reports it."""
|
|
|
|
title_slug: str
|
|
timestamp: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FakeSolve:
|
|
"""Guard's ``SolveProbe``, structurally."""
|
|
|
|
usable: bool
|
|
submissions: Sequence[FakeSubmission]
|
|
reason: str
|
|
|
|
|
|
@dataclass
|
|
class FakeProbe:
|
|
"""``recent_accepted`` as a switch: unusable, nothing, or an Accepted."""
|
|
|
|
status: str = "nothing"
|
|
timestamp_s: int = 0
|
|
calls: int = 0
|
|
|
|
def __call__(self) -> FakeSolve:
|
|
self.calls += 1
|
|
if self.status == "unusable":
|
|
return FakeSolve(False, (), "session expired")
|
|
rows = (
|
|
(FakeSubmission("missing-number", self.timestamp_s),)
|
|
if self.status == "accepted"
|
|
else ()
|
|
)
|
|
return FakeSolve(True, rows, "ok")
|
|
|
|
|
|
@dataclass
|
|
class Clock:
|
|
"""A monotonic clock the test moves (the probe's minute)."""
|
|
|
|
now: float = 1000.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
@dataclass
|
|
class Rig:
|
|
"""One relay and what drives it."""
|
|
|
|
relay: Relay
|
|
probe: FakeProbe
|
|
clock: Clock
|
|
sessions: Path
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
def make_rig(tmp_path: Path, *, start: bool = True, **deps: Any) -> Rig:
|
|
"""A relay on port 0 with fake fetch/probe and dry-run cards.
|
|
|
|
``heartbeat_s`` / ``rescan_s`` / ``pace_s`` go to the `Relay`, the rest to `Deps`.
|
|
"""
|
|
timing = {
|
|
k: deps.pop(k) for k in ("heartbeat_s", "rescan_s", "pace_s") if k in deps
|
|
}
|
|
probe, clock = FakeProbe(), Clock()
|
|
base: dict[str, Any] = {
|
|
"state_dir": tmp_path / "relay",
|
|
"fetch": lambda _slug: ("Missing Number", MISSING_NUMBER),
|
|
"probe": AcceptedProbe(probe, monotonic=clock),
|
|
"cards_dry_run": True,
|
|
}
|
|
relay = Relay(
|
|
Deps(**(base | deps)), port=0, tick_s=0.02, **({"pace_s": 0.0} | timing)
|
|
)
|
|
if start:
|
|
relay.start()
|
|
return Rig(relay, probe, clock, tmp_path / "sessions")
|
|
|
|
|
|
def http(rig: Rig, method: str, path: str, payload: object = None) -> tuple[int, Any]:
|
|
"""One loopback call; ``(status, decoded body)``."""
|
|
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode()
|
|
headers = {} if body is None else {"Content-Type": "application/json"}
|
|
conn = HTTPConnection("127.0.0.1", rig.relay.port, timeout=10)
|
|
try:
|
|
conn.request(method, path, body=body, headers=headers)
|
|
resp = conn.getresponse()
|
|
return resp.status, json.loads(resp.read())
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def call(rig: Rig, kind: str, **body: object) -> dict[str, Any]:
|
|
"""Send one request and poll until its result (what the app does)."""
|
|
rid_ = rid()
|
|
status, got = http(
|
|
rig,
|
|
"POST",
|
|
"/coach/requests",
|
|
{"rid": rid_, "kind": kind, "dev": DEV, "body": body},
|
|
)
|
|
if status == 200:
|
|
return dict(got)
|
|
deadline = time.monotonic() + 15
|
|
while time.monotonic() < deadline:
|
|
_status, polled = http(rig, "GET", f"/coach/requests/{rid_}")
|
|
if polled["result"] is not None:
|
|
return dict(polled["result"])
|
|
time.sleep(0.01)
|
|
msg = f"no result for {kind}"
|
|
raise AssertionError(msg)
|
|
|
|
|
|
def wait_for(check: Any, timeout: float = 5.0) -> Any:
|
|
"""Poll `check` until it returns something truthy."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if got := check():
|
|
return got
|
|
time.sleep(0.01)
|
|
msg = "condition never held"
|
|
raise AssertionError(msg)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
"""A throwaway HOME, so the guard's default data dir is the test's own."""
|
|
home = tmp_path / "home"
|
|
guard_dir_of(home).mkdir(parents=True)
|
|
monkeypatch.setenv("HOME", str(home))
|
|
|
|
|
|
def guard_dir_of(home: Path) -> Path:
|
|
"""Where leetcode-guard keeps its data under `home`."""
|
|
return home / ".local" / "share" / "leetcode-guard"
|
|
|
|
|
|
def guard_dir() -> Path:
|
|
"""The (redirected) guard data dir; refuses to name the real one."""
|
|
root = guard_dir_of(Path.home())
|
|
if "pytest-of-" not in str(root):
|
|
msg = f"{root} is not a pytest tmp dir: refusing to touch guard data"
|
|
raise RuntimeError(msg)
|
|
return root
|
|
|
|
|
|
def guard_digest() -> str:
|
|
"""A hash of every file under the (redirected) guard data dir."""
|
|
digest = hashlib.sha256()
|
|
root = guard_dir()
|
|
for path in sorted(root.rglob("*")):
|
|
digest.update(str(path.relative_to(root)).encode())
|
|
if path.is_file():
|
|
digest.update(path.read_bytes())
|
|
return digest.hexdigest()
|
|
|
|
|
|
def seed_guard() -> None:
|
|
"""Put a ledger and caches where the guard would read them."""
|
|
root = guard_dir()
|
|
(root / "ledger.json").write_text(
|
|
'{"entries": [{"k": "credit:1"}]}', encoding="utf-8"
|
|
)
|
|
(root / "pool_cache.json").write_text("{}", encoding="utf-8")
|