Skip to content

Commit 2fab9d4

Browse files
committed
Add record and offline fixture replay
One gzipped recording per host: the responses, and the result the spec produced when it was known good. Body lengths rather than bodies, since a fixture exists to notice a spec regression and not to fail on a one-word site edit. Recording refuses a spec that does not already pass, and gzip mtime is fixed so re-recording unchanged bytes is not a diff. Fixes try reporting FAILED when only optional fields were missing. Section 4.4 makes those warnings, because real pages omit them often enough that failing would reject working sources.
1 parent 8a476d2 commit 2fab9d4

4 files changed

Lines changed: 502 additions & 9 deletions

File tree

‎src/sourcelib/cli.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,53 @@ def _explain(url: str, as_json: bool, render: bool) -> int:
137137
return 0
138138

139139

140+
def _record(path: Path, url: str, root: Optional[Path]) -> int:
141+
from sourcelib.fixtures import Recording, RecordingFetcher, save
142+
from sourcelib.http import ScraperFetcher
143+
from sourcelib.trial import format_trial, run_trial
144+
145+
repo = _repo_root(root, [path])
146+
with ScraperFetcher(origin=url) as live:
147+
recorder = RecordingFetcher(live)
148+
trial = run_trial(path, url, recorder, root=repo)
149+
150+
if not trial.ok:
151+
# Recording a spec that does not work would bake the failure into the suite.
152+
print(format_trial(trial), file=sys.stderr)
153+
print("not recorded: make `try` pass first", file=sys.stderr)
154+
return 1
155+
156+
target = save(repo, path.stem, Recording(url, recorder.pages, trial.summary))
157+
print(f"recorded {len(recorder.pages)} page(s) to {target}")
158+
return 0
159+
160+
161+
def _fixtures(root: Optional[Path], strict: bool) -> int:
162+
from sourcelib.fixtures import hosts, replay
163+
164+
repo = _repo_root(root, [])
165+
known = hosts(repo)
166+
if not known:
167+
print("no recordings found", file=sys.stderr)
168+
return 1 if strict else 0
169+
170+
failed = 0
171+
for host in known:
172+
spec = repo / "specs" / f"{host}.yaml"
173+
if not spec.exists():
174+
failed += 1
175+
print(f"{host}: recorded, but specs/{host}.yaml is gone", file=sys.stderr)
176+
continue
177+
ok, differences = replay(repo, host, spec)
178+
if not ok:
179+
failed += 1
180+
for line in differences:
181+
print(f"{host}: {line}", file=sys.stderr)
182+
183+
print(f"{len(known) - failed} of {len(known)} recordings replay unchanged")
184+
return 1 if failed else 0
185+
186+
140187
def _schema(output: Optional[Path], check_only: bool) -> int:
141188
if output is None:
142189
print(render(), end="")
@@ -163,6 +210,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
163210
check = sub.add_parser("check", parents=[rooted], help="resolve and validate documents")
164211
check.add_argument("paths", nargs="*", type=Path, default=[Path(f) for f in FOLDERS])
165212
check.add_argument("--strict", action="store_true", help="fail when nothing was found")
213+
check.add_argument(
214+
"--fixtures",
215+
action="store_true",
216+
help="replay recordings instead, offline. Fixtures test the spec; only a live run "
217+
"tests the site",
218+
)
166219

167220
resolve = sub.add_parser(
168221
"resolve", parents=[rooted], help="print one document with its ancestors merged"
@@ -185,6 +238,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
185238
"a broken join across a multi-page body through",
186239
)
187240

241+
keep = sub.add_parser(
242+
"record", parents=[rooted], help="save a site's responses so a spec can be tested offline"
243+
)
244+
keep.add_argument("path", type=Path)
245+
keep.add_argument("url", help="a novel URL on that host")
246+
188247
digest = sub.add_parser(
189248
"explain", help="a structural digest of a page, for writing a spec against it"
190249
)
@@ -205,13 +264,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
205264
args = parser.parse_args(argv)
206265

207266
if args.command == "check":
267+
if args.fixtures:
268+
return _fixtures(args.root, args.strict)
208269
return _check(args.paths, args.root, args.strict)
209270
if args.command == "resolve":
210271
return _resolve(args.path, args.root, args.json)
211272
if args.command == "try":
212273
return _try(args.path, args.url, args.root, args.json, args.sample)
213274
if args.command == "explain":
214275
return _explain(args.url, args.json, args.render)
276+
if args.command == "record":
277+
return _record(args.path, args.url, args.root)
215278
if args.command == "schema":
216279
return _schema(args.output, args.check)
217280
return 1 # pragma: no cover - argparse rejects an unknown command first

‎src/sourcelib/fixtures.py‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Recording a site once so a spec can be tested without it.
2+
3+
A fixture turns spec repair into a deterministic loop: the same bytes every run, no network, no
4+
rate limit, and a diff that means the spec changed rather than the site did. That is what makes
5+
CI able to say a pull request broke something.
6+
7+
It is deliberately **not** a substitute for testing against the live site. A recorded page
8+
eventually stops resembling the real one, so a green fixture suite can mask a dead source.
9+
Fixtures test the spec; only a live run tests the site.
10+
11+
RFC-0001 leaves this format to the implementation, so it is one gzipped JSON document per host:
12+
the responses, and the result they produced when the spec was known good.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import gzip
18+
import json
19+
from pathlib import Path
20+
from typing import Any, Dict, List, Optional, Tuple
21+
22+
from sourcelib.fetch import Fetched, Fetcher
23+
from sourcelib.trial import COMPARED
24+
25+
__all__ = [
26+
"FIXTURE_NAME",
27+
"Recording",
28+
"RecordingFetcher",
29+
"ReplayFetcher",
30+
"compare",
31+
"hosts",
32+
"load",
33+
"path_for",
34+
"replay",
35+
"save",
36+
]
37+
38+
#: One file per host. Gzipped because a recorded page is mostly repetitive markup.
39+
FIXTURE_NAME = "recording.json.gz"
40+
41+
42+
class Recording:
43+
"""Responses captured from one host, and the result the spec produced from them."""
44+
45+
__slots__ = ("url", "pages", "expected")
46+
47+
def __init__(
48+
self,
49+
url: str = "",
50+
pages: Optional[Dict[str, Dict[str, Any]]] = None,
51+
expected: Optional[Dict[str, Any]] = None,
52+
) -> None:
53+
self.url = url
54+
self.pages = pages or {}
55+
self.expected = expected or {}
56+
57+
def to_dict(self) -> Dict[str, Any]:
58+
return {"url": self.url, "pages": self.pages, "expected": self.expected}
59+
60+
def fetcher(self) -> "ReplayFetcher":
61+
return ReplayFetcher(self.pages)
62+
63+
64+
class RecordingFetcher:
65+
"""Wraps a Fetcher and keeps everything it retrieved."""
66+
67+
def __init__(self, inner: Fetcher) -> None:
68+
self.inner = inner
69+
self.pages: Dict[str, Dict[str, Any]] = {}
70+
71+
def fetch(self, method: str, url: str, **options: Any) -> Fetched:
72+
response = self.inner.fetch(method, url, **options)
73+
self._keep(method, url, response)
74+
return response
75+
76+
def render(self, url: str, *, wait_for: Optional[str] = None) -> Fetched:
77+
response = self.inner.render(url, wait_for=wait_for)
78+
self._keep("RENDER", url, response)
79+
return response
80+
81+
def _keep(self, method: str, url: str, response: Fetched) -> None:
82+
# Keyed by the requested URL rather than the final one, because replay looks up what
83+
# the spec asked for, not where a redirect landed.
84+
self.pages[url] = {
85+
"method": method,
86+
"final_url": response.url,
87+
"status": response.status,
88+
"headers": dict(response.headers),
89+
"text": response.text,
90+
}
91+
92+
93+
class ReplayFetcher:
94+
"""Answers from a recording, and says plainly when a spec asks for something unrecorded."""
95+
96+
def __init__(self, pages: Dict[str, Dict[str, Any]]) -> None:
97+
self.pages = pages
98+
self.missing: List[str] = []
99+
100+
def fetch(self, method: str, url: str, **options: Any) -> Fetched:
101+
entry = self.pages.get(url)
102+
if entry is None:
103+
self.missing.append(url)
104+
# A spec reaching a URL the recording does not hold usually means the spec changed
105+
# its request, which is worth reporting rather than silently passing.
106+
from sourcelib.fetch import FetchError
107+
108+
raise FetchError(
109+
f"not in the recording: {url}. Re-record if the spec changed on purpose"
110+
)
111+
return Fetched(
112+
url=entry.get("final_url") or url,
113+
text=entry.get("text", ""),
114+
headers=entry.get("headers") or {},
115+
status=int(entry.get("status", 200)),
116+
)
117+
118+
def render(self, url: str, *, wait_for: Optional[str] = None) -> Fetched:
119+
return self.fetch("GET", url)
120+
121+
122+
def path_for(root: Path, host: str) -> Path:
123+
return Path(root) / "fixtures" / host / FIXTURE_NAME
124+
125+
126+
def save(root: Path, host: str, recording: Recording) -> Path:
127+
target = path_for(root, host)
128+
target.parent.mkdir(parents=True, exist_ok=True)
129+
payload = json.dumps(recording.to_dict(), ensure_ascii=False, sort_keys=True).encode("utf-8")
130+
# mtime=0 so re-recording identical bytes produces an identical file rather than a diff.
131+
with gzip.GzipFile(filename=str(target), mode="wb", mtime=0) as handle:
132+
handle.write(payload)
133+
return target
134+
135+
136+
def load(root: Path, host: str) -> Recording:
137+
target = path_for(root, host)
138+
with gzip.open(target, "rb") as handle:
139+
data = json.loads(handle.read().decode("utf-8"))
140+
return Recording(
141+
url=data.get("url", ""),
142+
pages=data.get("pages") or {},
143+
expected=data.get("expected") or {},
144+
)
145+
146+
147+
def hosts(root: Path) -> List[str]:
148+
"""Every host with a recording, in order."""
149+
folder = Path(root) / "fixtures"
150+
if not folder.is_dir():
151+
return []
152+
return sorted(p.parent.name for p in folder.glob(f"*/{FIXTURE_NAME}"))
153+
154+
155+
def compare(expected: Dict[str, Any], actual: Dict[str, Any]) -> List[str]:
156+
"""Every difference between a recorded result and a fresh one, as readable lines."""
157+
differences: List[str] = []
158+
159+
for name in (*COMPARED, "chapters", "volumes", "first_chapter", "last_chapter"):
160+
if name not in expected:
161+
continue
162+
was, now = expected[name], actual.get(name)
163+
if was != now:
164+
differences.append(f"{name}: expected {_short(was)}, got {_short(now)}")
165+
166+
for was, now in zip(expected.get("bodies") or [], actual.get("bodies") or []):
167+
if was.get("characters") != now.get("characters"):
168+
differences.append(
169+
f"chapter {was.get('id')}: body was {was.get('characters')} characters, "
170+
f"now {now.get('characters')}"
171+
)
172+
return differences
173+
174+
175+
def _short(value: Any) -> str:
176+
text = json.dumps(value, ensure_ascii=False) if not isinstance(value, str) else repr(value)
177+
return text if len(text) <= 60 else text[:59] + "…"
178+
179+
180+
def replay(root: Path, host: str, spec_path: Path, sample: int = 3) -> Tuple[bool, List[str]]:
181+
"""Run a spec against its recording and report what differs."""
182+
from sourcelib.trial import run_trial
183+
184+
recording = load(root, host)
185+
trial = run_trial(spec_path, recording.url, recording.fetcher(), root=root, sample=sample)
186+
if trial.error:
187+
return False, [trial.error]
188+
differences = compare(recording.expected, trial.summary)
189+
return not differences, differences

‎src/sourcelib/trial.py‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,24 @@
2828
from sourcelib.spec.model import SourceSpec
2929
from sourcelib.spec.resolve import resolve_document
3030

31-
__all__ = ["Finding", "Trial", "format_trial", "run_trial"]
31+
__all__ = ["COMPARED", "Finding", "Trial", "format_trial", "run_trial", "summarise"]
3232

3333
#: How much of a value to show. Enough to tell right from wrong, short enough to scan.
3434
PREVIEW = 160
3535

3636

3737
@dataclass
3838
class Finding:
39-
"""One thing a run learned about one field."""
39+
"""One thing a run learned about one field.
40+
41+
`required` is what separates a failure from a warning. Section 4.4 makes a missing cover,
42+
author, tag list or synopsis a warning, because real pages omit them often enough that
43+
failing would reject working sources.
44+
"""
4045

4146
field: str
4247
ok: bool
48+
required: bool = True
4349
detail: str = ""
4450
count: Optional[int] = None
4551
preview: str = ""
@@ -68,6 +74,9 @@ class Trial:
6874
chapters: int = 0
6975
volumes: int = 0
7076
sampled: List[Dict[str, Any]] = field(default_factory=list)
77+
#: The values a fixture records and compares. Kept here so recording and replaying read
78+
#: the same structure rather than each reconstructing it.
79+
summary: Dict[str, Any] = field(default_factory=dict)
7180
error: Optional[str] = None
7281

7382
def to_dict(self) -> Dict[str, Any]:
@@ -105,10 +114,17 @@ def run_trial(
105114

106115
trial = Trial(host=path.stem, url=url)
107116

108-
def note(field_name: str, ok: bool, detail: str = "", value: Any = None) -> None:
117+
def note(
118+
field_name: str,
119+
ok: bool,
120+
detail: str = "",
121+
value: Any = None,
122+
required: bool = True,
123+
) -> None:
109124
trial.add(
110125
field=field_name,
111126
ok=ok,
127+
required=required,
112128
detail=detail,
113129
count=_count(value),
114130
preview=_preview(value),
@@ -152,16 +168,40 @@ def note(field_name: str, ok: bool, detail: str = "", value: Any = None) -> None
152168
trial.sampled.append(_try_chapter(interpreter, novel, chapter, note))
153169

154170
_carry(trial, report)
155-
trial.ok = trial.error is None and all(f.ok for f in trial.findings)
171+
trial.summary = summarise(novel, trial.sampled)
172+
trial.ok = trial.error is None and all(f.ok for f in trial.findings if f.required)
156173
return trial
157174

158175

176+
#: What a fixture stores and compares.
177+
COMPARED = ("title", "cover_url", "authors", "tags", "synopsis", "language")
178+
179+
180+
def summarise(novel: Novel, sampled: List[Dict[str, Any]]) -> Dict[str, Any]:
181+
"""The result in the form a fixture records.
182+
183+
Chapter *count* rather than the chapters, and body *lengths* rather than bodies: a fixture
184+
exists to notice a spec regression, and storing every body would make the file enormous
185+
while making a one-word site change fail the suite.
186+
"""
187+
summary: Dict[str, Any] = {name: getattr(novel, name) for name in COMPARED}
188+
summary["chapters"] = len(novel.chapters)
189+
summary["volumes"] = len(novel.volumes)
190+
summary["first_chapter"] = novel.chapters[0].title if novel.chapters else ""
191+
summary["last_chapter"] = novel.chapters[-1].title if novel.chapters else ""
192+
summary["bodies"] = [{"id": s.get("id"), "characters": s.get("characters", 0)} for s in sampled]
193+
return summary
194+
195+
159196
def _note_novel(note: Any, novel: Novel) -> None:
160197
note("novel.title", bool(novel.title), "", novel.title)
161-
note("novel.cover", bool(novel.cover_url), "", novel.cover_url)
162-
note("novel.authors", bool(novel.authors), "", novel.authors)
163-
note("novel.tags", bool(novel.tags), "", novel.tags)
164-
note("novel.synopsis", bool(novel.synopsis), "", novel.synopsis)
198+
for name, value in (
199+
("cover", novel.cover_url),
200+
("authors", novel.authors),
201+
("tags", novel.tags),
202+
("synopsis", novel.synopsis),
203+
):
204+
note(f"novel.{name}", bool(value), "", value, required=False)
165205

166206

167207
def _sample(novel: Novel, count: int) -> List[Chapter]:
@@ -212,7 +252,7 @@ def format_trial(trial: Trial) -> str:
212252
out: List[str] = [f"{trial.host} {trial.url}", ""]
213253

214254
for finding in trial.findings:
215-
mark = "ok " if finding.ok else "FAIL"
255+
mark = "ok " if finding.ok else ("FAIL" if finding.required else "none")
216256
detail = f" {finding.detail}" if finding.detail else ""
217257
out.append(f" {mark} {finding.field}{detail}{finding.where()}")
218258
if finding.preview:

0 commit comments

Comments
 (0)