craftkit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
craft_kit/browser.py ADDED
@@ -0,0 +1,198 @@
1
+ """Photographing pages as a visitor sees them, with Playwright: the Python package when it's
2
+ installed, else the Playwright CLI through npx. Nothing here reads a page's source."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import tempfile
9
+ import threading
10
+ from contextlib import contextmanager
11
+ from functools import partial
12
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
13
+ from pathlib import Path
14
+ from typing import Iterator, Optional
15
+
16
+ from . import proc, tables
17
+
18
+ HEIGHT = 900
19
+ FULL_HEIGHT = 2400 # Chrome photographs the window, not the whole page: make it tall
20
+ INSTALL_HINT = f"Install a browser for screenshots: `pip install playwright && playwright install chromium`, or `npx -y {tables.PLAYWRIGHT} install chromium`."
21
+
22
+ # What a visitor can see, measured in the page (no source is saved).
23
+ FACTS_JS = """() => {
24
+ const pick = (sel) => { const e = document.querySelector(sel); if (!e) return null;
25
+ const s = getComputedStyle(e);
26
+ return {font: s.fontFamily, size: s.fontSize, weight: s.fontWeight, color: s.color, background: s.backgroundColor, radius: s.borderRadius}; };
27
+ const text = (sel, n) => Array.from(document.querySelectorAll(sel)).slice(0, n).map(e => e.innerText.trim()).filter(Boolean);
28
+ return {
29
+ title: document.title,
30
+ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight,
31
+ styles: {body: pick('body'), h1: pick('h1'), h2: pick('h2'), link: pick('a'), button: pick('button, .btn, [role=button]'), nav: pick('nav, header')},
32
+ headings: text('h1, h2, h3', 60), links: text('nav a, header a', 40), buttons: text('button, [role=button]', 40),
33
+ sections: document.querySelectorAll('section, main > *').length,
34
+ text: document.body ? document.body.innerText.slice(0, 20000) : ''
35
+ };
36
+ }"""
37
+
38
+
39
+ CHROME_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "msedge", "microsoft-edge"]
40
+ CHROME_PATHS = [
41
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
42
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
43
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
44
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
45
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
46
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
47
+ ]
48
+
49
+
50
+ def chrome() -> Optional[str]:
51
+ if os.environ.get("CRAFT_CHROME"):
52
+ return os.environ["CRAFT_CHROME"]
53
+ for n in CHROME_NAMES:
54
+ found = proc.which(n)
55
+ if found:
56
+ return found
57
+ return next((p for p in CHROME_PATHS if os.path.isfile(p)), None)
58
+
59
+
60
+ def engine() -> Optional[str]:
61
+ """Python Playwright (screenshots + visible facts), else an installed Chrome/Edge in headless
62
+ mode (screenshots), else the Playwright CLI through npx (downloads it on first use)."""
63
+ forced = os.environ.get("CRAFT_BROWSER")
64
+ if forced in ("python", "chrome", "npx", "none"):
65
+ return None if forced == "none" else forced
66
+ try:
67
+ import playwright.sync_api # type: ignore # noqa: F401
68
+
69
+ return "python"
70
+ except ImportError:
71
+ pass
72
+ if chrome():
73
+ return "chrome"
74
+ return "npx" if proc.which("npx") else None
75
+
76
+
77
+ def _chrome_shot(url: str, dest: Path, width: int) -> tuple[bool, str]:
78
+ exe = chrome()
79
+ if not exe:
80
+ return False, "no Chrome or Edge found"
81
+ with tempfile.TemporaryDirectory(prefix="craft-chrome-") as profile: # never the person's own profile
82
+ r = proc.run(
83
+ [exe, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run", "--no-default-browser-check",
84
+ f"--user-data-dir={profile}", f"--window-size={width},{FULL_HEIGHT}", "--virtual-time-budget=4000",
85
+ f"--screenshot={dest}", url],
86
+ timeout=120,
87
+ idle=90,
88
+ )
89
+ return dest.is_file(), r.error or r.tail(3)
90
+
91
+
92
+ def capture(url: str, out: Path, widths: list[int], facts: bool, live=None) -> tuple[list[Path], dict, list[str]]:
93
+ """Screenshots at each width (screenshot-<w>.png), visitor facts at the first width when the
94
+ Python engine is there, and the problems met, in plain words."""
95
+ out.mkdir(parents=True, exist_ok=True)
96
+ kind = engine()
97
+ if kind is None:
98
+ return [], {}, [f"No browser to photograph pages. {INSTALL_HINT}"]
99
+ if kind == "python":
100
+ return _capture_python(url, out, widths, facts)
101
+ shots, problems = [], []
102
+ if kind == "chrome":
103
+ for w in widths:
104
+ if live:
105
+ live.event("web", f"screenshot at {w} px")
106
+ dest = out / f"screenshot-{w}.png"
107
+ ok, why = _chrome_shot(url, dest, w)
108
+ if ok:
109
+ shots.append(dest)
110
+ else:
111
+ problems.append(f"{w} px: {why}")
112
+ return shots, {}, problems
113
+ for w in widths:
114
+ if live:
115
+ live.event("web", f"screenshot at {w} px")
116
+ dest = out / f"screenshot-{w}.png"
117
+ r = proc.run(
118
+ ["npx", "-y", tables.PLAYWRIGHT, "screenshot", "--full-page", f"--viewport-size={w},{HEIGHT}", "--wait-for-timeout=1500", url, str(dest)],
119
+ timeout=240,
120
+ idle=180,
121
+ )
122
+ if r.ok and dest.is_file():
123
+ shots.append(dest)
124
+ else:
125
+ problems.append(f"{w} px: {r.error or r.tail(3)}")
126
+ if problems and not shots:
127
+ problems.append(INSTALL_HINT)
128
+ return shots, {}, problems
129
+
130
+
131
+ def _capture_python(url: str, out: Path, widths: list[int], facts: bool) -> tuple[list[Path], dict, list[str]]:
132
+ from playwright.sync_api import Error, sync_playwright # type: ignore
133
+
134
+ shots: list[Path] = []
135
+ found: dict = {}
136
+ problems: list[str] = []
137
+ try:
138
+ with sync_playwright() as pw:
139
+ browser = pw.chromium.launch()
140
+ try:
141
+ for i, w in enumerate(widths):
142
+ page = browser.new_page(viewport={"width": w, "height": HEIGHT})
143
+ try:
144
+ page.goto(url, wait_until="networkidle", timeout=60_000)
145
+ except Error:
146
+ page.wait_for_timeout(1500) # a page that never goes quiet still has something to see
147
+ page.wait_for_timeout(800)
148
+ dest = out / f"screenshot-{w}.png"
149
+ page.screenshot(path=str(dest), full_page=True)
150
+ shots.append(dest)
151
+ if facts:
152
+ try:
153
+ found[str(w)] = page.evaluate(FACTS_JS) if i == 0 else {k: v for k, v in page.evaluate(FACTS_JS).items() if k in ("width", "height", "styles", "sections")}
154
+ except Error as e:
155
+ problems.append(f"couldn't measure the page at {w} px: {e}")
156
+ page.close()
157
+ finally:
158
+ browser.close()
159
+ except Exception as e: # Playwright raises its own errors, and a missing browser is one of them
160
+ problems.append(f"the browser failed: {str(e).splitlines()[0] if str(e) else type(e).__name__}. {INSTALL_HINT}")
161
+ return shots, found, problems
162
+
163
+
164
+ class _Quiet(SimpleHTTPRequestHandler):
165
+ def log_message(self, *args) -> None: # keep the terminal clean
166
+ pass
167
+
168
+
169
+ @contextmanager
170
+ def serve(folder: Path) -> Iterator[str]:
171
+ """Serve a folder on loopback for as long as the block runs."""
172
+ server = ThreadingHTTPServer(("127.0.0.1", 0), partial(_Quiet, directory=str(folder)))
173
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
174
+ thread.start()
175
+ try:
176
+ yield f"http://127.0.0.1:{server.server_address[1]}/"
177
+ finally:
178
+ server.shutdown()
179
+ server.server_close()
180
+
181
+
182
+ def facts_markdown(facts: dict) -> str:
183
+ if not facts:
184
+ return ""
185
+ first = facts.get(next(iter(facts)), {})
186
+ lines = ["## What a visitor sees", ""]
187
+ if first.get("title"):
188
+ lines.append(f"Title: {first['title']}")
189
+ for w, f in facts.items():
190
+ lines.append(f"- {w} px: page {f.get('width')}×{f.get('height')} px, {f.get('sections')} sections")
191
+ styles = first.get("styles") or {}
192
+ if styles:
193
+ lines += ["", "Styles (computed, first width):", "```json", json.dumps(styles, indent=1, ensure_ascii=False), "```"]
194
+ for key, label in (("headings", "Headings"), ("links", "Navigation"), ("buttons", "Buttons")):
195
+ if first.get(key):
196
+ lines.append(f"\n{label}: " + " · ".join(first[key][:30]))
197
+ lines.append("\nThe visible text is in capture.json (`text`).")
198
+ return "\n".join(lines) + "\n"
craft_kit/checks.py ADDED
@@ -0,0 +1,219 @@
1
+ """Check: judge the build against the original, the same way every time, and say what differs.
2
+
3
+ Each check returns {"id", "passed": True|False|None (couldn't judge), "score": percent|None,
4
+ "summary", "claims": [[text, ok, detail]]}. A round is good when no check failed and every score
5
+ reaches the project's good_enough."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Callable, Optional
13
+
14
+ from . import browser, png, proc, tables
15
+ from .ui import UI
16
+ from .workspace import Project, read_json, write_atomic
17
+
18
+ BUILD_TIMEOUT = 20 * 60
19
+ ALIKE = 80.0 # a run's output counts as the same when this many of its words match
20
+
21
+
22
+ def result(cid: str, passed: Optional[bool], summary: str, score: Optional[float] = None, claims: Optional[list] = None) -> dict:
23
+ return {"id": cid, "passed": passed, "score": None if score is None else round(score, 1), "summary": summary, "claims": claims or []}
24
+
25
+
26
+ def check_build(p: Project, rd: Path, live) -> dict:
27
+ spec = p.app_spec()
28
+ if not any(p.app.iterdir()):
29
+ return result("build", False, "app/ is empty: nothing has been built yet.")
30
+ if "install" in spec and not (p.app / "node_modules").exists():
31
+ live.event("run", "installing dependencies: " + " ".join(spec["install"]))
32
+ r = proc.run(spec["install"], cwd=str(p.app), timeout=BUILD_TIMEOUT, idle=600, on_line=live.update)
33
+ if not r.ok:
34
+ return result("build", False, f"Installing dependencies failed ({r.error or f'exit {r.code}'}):\n{r.tail(25)}")
35
+ if "build" not in spec:
36
+ return result("build", True, "Nothing to build (no build command declared or found).")
37
+ live.event("run", " ".join(spec["build"]))
38
+ r = proc.run(spec["build"], cwd=str(p.app), timeout=BUILD_TIMEOUT, idle=600, on_line=live.update)
39
+ write_atomic(rd / "build.log", r.output)
40
+ if r.ok:
41
+ return result("build", True, f"`{' '.join(spec['build'])}` succeeds.")
42
+ return result("build", False, f"`{' '.join(spec['build'])}` fails ({r.error or f'exit {r.code}'}):\n{r.tail(30)}")
43
+
44
+
45
+ def check_tests(p: Project, rd: Path, live) -> dict:
46
+ spec = p.app_spec()
47
+ if "test" not in spec:
48
+ return result("tests", None, "No tests declared or found.")
49
+ live.event("run", " ".join(spec["test"]))
50
+ r = proc.run(spec["test"], cwd=str(p.app), timeout=BUILD_TIMEOUT, idle=600, on_line=live.update)
51
+ write_atomic(rd / "tests.log", r.output)
52
+ low = r.output.lower()
53
+ if any(s in low for s in tables.NO_TESTS):
54
+ return result("tests", None, "The project has no tests yet.")
55
+ if r.ok:
56
+ return result("tests", True, "The tests pass.")
57
+ return result("tests", False, f"The tests fail ({r.error or f'exit {r.code}'}):\n{r.tail(30)}")
58
+
59
+
60
+ def check_web(p: Project, rd: Path, live) -> dict:
61
+ site = p.site_dir()
62
+ if site is None:
63
+ return result("web", False, "No built site found: there must be an index.html in app/dist, app/out, app/build, app/public or app/ (or the folder named by `site` in app/craft-app.json).")
64
+ with browser.serve(site) as url:
65
+ live.event("web", f"photographing the build at {url}")
66
+ shots, _facts, problems = browser.capture(url, rd, tables.WIDTHS, facts=False, live=live)
67
+ if not shots:
68
+ return result("web", None, "The build couldn't be photographed: " + "; ".join(problems))
69
+ claims, scores = [], []
70
+ for shot in shots:
71
+ mine = png.grid(shot)
72
+ if mine is None or mine.blank():
73
+ claims.append([f"At {shot.stem.split('-')[-1]} px, the page shows something", False, "the screenshot is blank"])
74
+ scores.append(0.0)
75
+ continue
76
+ original = p.capture / shot.name
77
+ theirs = png.grid(original) if original.is_file() else None
78
+ if theirs is None:
79
+ claims.append([f"At {shot.stem.split('-')[-1]} px, the page draws", True, "no original to compare with"])
80
+ continue
81
+ s = png.similarity(mine, theirs)
82
+ scores.append(s)
83
+ claims.append([f"At {shot.stem.split('-')[-1]} px, it looks like the original", s >= p.state["good_enough"], f"{s:.0f}% alike", shot.stem.split("-")[-1]])
84
+ score = sum(scores) / len(scores) if scores else None
85
+ if score is None:
86
+ return result("web", True, "The pages draw at every width (nothing to compare them with).", None, claims)
87
+ detail = ", ".join(f"{c[3]} px {c[2]}" for c in claims if len(c) > 3)
88
+ claims = [c[:3] for c in claims]
89
+ return result("web", all(c[1] for c in claims), f"Looks {score:.0f}% like the original ({detail}). Your screenshots are in {rd.relative_to(p.root)}/, the original's in capture/.", score, claims)
90
+
91
+
92
+ def _run_spec(p: Project) -> Optional[list]:
93
+ run = p.app_spec().get("run")
94
+ return list(run) if run else None
95
+
96
+
97
+ def _word_alike(a: str, b: str) -> float:
98
+ wa, wb = set(a.lower().split()), set(b.lower().split())
99
+ if not wa and not wb:
100
+ return 100.0
101
+ return 100.0 * len(wa & wb) / max(1, len(wa | wb))
102
+
103
+
104
+ def check_runs(p: Project, rd: Path, live) -> dict:
105
+ recorded = read_json(p.capture / "runs.json", {})
106
+ runs = recorded.get("runs") if isinstance(recorded, dict) else None
107
+ if not runs:
108
+ return result("runs", None, "No runs of the original were recorded (start with --allow-run on a program to record them).")
109
+ cmd = _run_spec(p)
110
+ if not cmd:
111
+ return result("runs", False, 'app/craft-app.json has no "run" command, so the rebuild can\'t be run with the same inputs.')
112
+ claims, scores, mine = [], [], []
113
+ for run in runs:
114
+ args = [a for a in run.get("args", []) if isinstance(a, str)]
115
+ live.event("run", " ".join(args) or "(no arguments)")
116
+ r = proc.run([*cmd, *args], cwd=str(p.app), timeout=15, cap=16_000)
117
+ mine.append({"args": args, "code": r.code, "output": r.output, "error": r.error})
118
+ label = " ".join(args) or "no arguments"
119
+ claims.append([f"With {label}, it exits with code {run.get('code')}", r.code == run.get("code"), f"it exits with {r.code}" + (f" ({r.error})" if r.error else "")])
120
+ s = _word_alike(r.output, run.get("output", ""))
121
+ scores.append(s)
122
+ claims.append([f"With {label}, it prints what the original prints", s >= ALIKE, f"{s:.0f}% of the words match"])
123
+ write_atomic(rd / "runs.json", json.dumps({"version": 1, "runs": mine}, indent=1, ensure_ascii=False))
124
+ score = sum(scores) / len(scores)
125
+ bad = [c for c in claims if not c[1]]
126
+ return result("runs", not bad, f"{len(claims) - len(bad)} of {len(claims)} claims hold; output {score:.0f}% alike.", score, claims)
127
+
128
+
129
+ def check_screenshot(p: Project, rd: Path, live) -> dict:
130
+ cmd = _run_spec(p)
131
+ if not cmd:
132
+ return result("screenshot", False, 'app/craft-app.json has no "run" command, so the program can\'t be started.')
133
+ dest = rd / "screenshot.png"
134
+ live.event("run", "starting it with --screenshot")
135
+ r = proc.run([*cmd, "--screenshot", str(dest)], cwd=str(p.app), timeout=90, idle=60)
136
+ g = png.grid(dest) if dest.is_file() else None
137
+ if g is None:
138
+ return result("screenshot", False, f"The program didn't save a readable PNG with --screenshot ({r.error or f'exit {r.code}'}). Contract: start, draw about two seconds of frames, save the window to that file, exit.")
139
+ if g.blank():
140
+ return result("screenshot", False, "The --screenshot picture is one colour: nothing was drawn before saving.")
141
+ refs = [x for x in sorted(p.reference.glob("*.png"))] if p.reference.exists() else []
142
+ best = None
143
+ for ref in refs:
144
+ rg = png.grid(ref)
145
+ if rg is not None:
146
+ s = png.similarity(g, rg)
147
+ best = s if best is None else max(best, s)
148
+ if best is None:
149
+ return result("screenshot", True, f"It starts and draws ({g.width}×{g.height}).")
150
+ return result("screenshot", True, f"It starts and draws ({g.width}×{g.height}); about {best:.0f}% like the closest picture in reference/.", best)
151
+
152
+
153
+ def check_selftest(p: Project, rd: Path, live) -> dict:
154
+ cmd = _run_spec(p)
155
+ if not cmd:
156
+ return result("selftest", False, 'app/craft-app.json has no "run" command, so the game can\'t be started.')
157
+ dest = rd / "self-test.json"
158
+ live.event("run", "playing the core loop with --self-test")
159
+ r = proc.run([*cmd, "--self-test", str(dest)], cwd=str(p.app), timeout=120, idle=90)
160
+ report = read_json(dest)
161
+ if not isinstance(report, dict) or not isinstance(report.get("steps"), list):
162
+ return result("selftest", False, f"The game didn't save a report with --self-test ({r.error or f'exit {r.code}'}). Contract: {{\"version\": 1, \"steps\": [{{\"step\": \"move\", \"passed\": true, \"detail\": \"…\"}}]}} with steps move, act and hud measured from the game's own state.")
163
+ claims = [[str(s.get("step")), bool(s.get("passed")), str(s.get("detail", ""))] for s in report["steps"] if isinstance(s, dict)]
164
+ have = {c[0] for c in claims}
165
+ for need in ("move", "act", "hud"):
166
+ if need not in have:
167
+ claims.append([need, False, "not measured"])
168
+ bad = [c for c in claims if not c[1]]
169
+ return result("selftest", not bad, "The core loop works." if not bad else f"{len(bad)} of {len(claims)} steps don't pass: " + ", ".join(c[0] for c in bad), None, claims)
170
+
171
+
172
+ CHECKS: dict[str, Callable] = {"build": check_build, "tests": check_tests, "web": check_web, "runs": check_runs, "screenshot": check_screenshot, "selftest": check_selftest}
173
+
174
+
175
+ def good(res: dict, good_enough: float) -> bool:
176
+ return all(c["passed"] is not False and (c["score"] is None or c["score"] >= good_enough) for c in res["checks"])
177
+
178
+
179
+ def run_checks(p: Project, round_no: int, ui: UI) -> dict:
180
+ rd = p.round_dir(round_no)
181
+ rd.mkdir(parents=True, exist_ok=True)
182
+ out = []
183
+ ids = tables.KINDS.get(p.state["kind"], tables.KINDS["tool"])["checks"]
184
+ for cid in ids:
185
+ with ui.live(f"check {cid}", "check") as live:
186
+ try:
187
+ res = CHECKS[cid](p, rd, live)
188
+ except Exception as e: # a check that breaks says so; it never stops the run
189
+ res = result(cid, None, f"The check itself failed: {type(e).__name__}: {e}")
190
+ out.append(res)
191
+ if cid == "build" and res["passed"] is False:
192
+ break # nothing else can be judged on a build that doesn't build
193
+ res = {"version": 1, "round": round_no, "time": time.strftime("%Y-%m-%dT%H:%M:%S"), "checks": out}
194
+ res["good"] = good(res, p.state["good_enough"])
195
+ write_atomic(rd / "result.json", json.dumps(res, indent=1, ensure_ascii=False))
196
+ p.log(f"check round {round_no}: {'good' if res['good'] else 'not yet'} " + "; ".join(f"{c['id']}={c['passed']}" for c in out))
197
+ return res
198
+
199
+
200
+ def show(res: dict, ui: UI) -> None:
201
+ for c in res["checks"]:
202
+ state = "skip" if c["passed"] is None else ("done" if c["passed"] else "fail")
203
+ first, *rest = c["summary"].splitlines() or [""]
204
+ ui.step(state, c["id"], first, stage="check")
205
+ if c["passed"] is False and rest:
206
+ ui.note("\n".join(rest[-12:]))
207
+ for text, ok, detail in c["claims"]:
208
+ if not ok:
209
+ ui.note(f"{text}: {detail}")
210
+
211
+
212
+ def refine_text(res: dict) -> str:
213
+ """What the builder is told after a round that isn't good yet."""
214
+ lines = [f"Check {res['round']} isn't passing yet:"]
215
+ for c in res["checks"]:
216
+ if c["passed"] is False or (c["score"] is not None and not res["good"]):
217
+ lines.append(f"\n[{c['id']}] {c['summary']}")
218
+ lines += [f"- {t}: {d}" for t, ok, d in c["claims"] if not ok]
219
+ return "\n".join(lines)