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/SKILL.md +145 -0
- craft_kit/__init__.py +3 -0
- craft_kit/__main__.py +5 -0
- craft_kit/agent.py +165 -0
- craft_kit/banner.py +86 -0
- craft_kit/brief.py +101 -0
- craft_kit/browser.py +198 -0
- craft_kit/checks.py +219 -0
- craft_kit/cli.py +394 -0
- craft_kit/home.py +370 -0
- craft_kit/png.py +135 -0
- craft_kit/proc.py +161 -0
- craft_kit/study.py +170 -0
- craft_kit/tables.py +154 -0
- craft_kit/ui.py +284 -0
- craft_kit/workspace.py +320 -0
- craftkit-0.3.0.dist-info/METADATA +174 -0
- craftkit-0.3.0.dist-info/RECORD +23 -0
- craftkit-0.3.0.dist-info/WHEEL +5 -0
- craftkit-0.3.0.dist-info/entry_points.txt +5 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-APACHE +177 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-MIT +21 -0
- craftkit-0.3.0.dist-info/top_level.txt +1 -0
craft_kit/home.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""The home screen: `craft` on its own. The title, past creations to browse with the arrow keys,
|
|
2
|
+
and a new project in a few questions. Actions run in the normal terminal, then come back here."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Optional
|
|
12
|
+
|
|
13
|
+
from . import __version__, banner, proc, tables
|
|
14
|
+
from .ui import UI
|
|
15
|
+
from .workspace import CraftError, Project, forget, parse_target, projects_dir, read_json, remembered, slug, STATE
|
|
16
|
+
|
|
17
|
+
ALT_ON, ALT_OFF = "\033[?1049h", "\033[?1049l"
|
|
18
|
+
DESCRIPTION = [
|
|
19
|
+
("Study", "looks at the original: screenshots, what it prints, its files (Deep mode)"),
|
|
20
|
+
("Build", "your agent (Claude Code, Codex, Gemini) writes a new version in app/"),
|
|
21
|
+
("Check", "compares it with the original and measures what differs"),
|
|
22
|
+
("Refine", "sends the differences back until every check passes"),
|
|
23
|
+
]
|
|
24
|
+
HIDE, SHOW = "\033[?25l", "\033[?25h"
|
|
25
|
+
CLEAR = "\033[H\033[2J"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- keys -----------------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def read_key() -> str:
|
|
31
|
+
"""One key press: up, down, enter, esc, backspace, or the character itself."""
|
|
32
|
+
if os.name == "nt":
|
|
33
|
+
import msvcrt # type: ignore
|
|
34
|
+
|
|
35
|
+
ch = msvcrt.getwch()
|
|
36
|
+
if ch in ("\x00", "\xe0"):
|
|
37
|
+
return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(msvcrt.getwch(), "")
|
|
38
|
+
if ch == "\x03":
|
|
39
|
+
raise KeyboardInterrupt
|
|
40
|
+
return {"\r": "enter", "\x1b": "esc", "\x08": "backspace"}.get(ch, ch)
|
|
41
|
+
import select
|
|
42
|
+
import termios
|
|
43
|
+
import tty
|
|
44
|
+
|
|
45
|
+
fd = sys.stdin.fileno()
|
|
46
|
+
old = termios.tcgetattr(fd)
|
|
47
|
+
try:
|
|
48
|
+
tty.setraw(fd)
|
|
49
|
+
ch = os.read(fd, 1)
|
|
50
|
+
if ch == b"\x1b":
|
|
51
|
+
seq = b""
|
|
52
|
+
while len(seq) < 2 and select.select([fd], [], [], 0.04)[0]:
|
|
53
|
+
seq += os.read(fd, 1)
|
|
54
|
+
return {b"[A": "up", b"[B": "down", b"[C": "right", b"[D": "left", b"OA": "up", b"OB": "down"}.get(seq, "esc")
|
|
55
|
+
finally:
|
|
56
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
57
|
+
if ch == b"\x03":
|
|
58
|
+
raise KeyboardInterrupt
|
|
59
|
+
if ch in (b"\r", b"\n"):
|
|
60
|
+
return "enter"
|
|
61
|
+
if ch in (b"\x7f", b"\x08"):
|
|
62
|
+
return "backspace"
|
|
63
|
+
return ch.decode("utf-8", "replace")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- what a project looks like in the list -------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def ago(iso: str) -> str:
|
|
69
|
+
try:
|
|
70
|
+
t = time.mktime(time.strptime(iso, "%Y-%m-%dT%H:%M:%S"))
|
|
71
|
+
except (ValueError, OverflowError):
|
|
72
|
+
return ""
|
|
73
|
+
s = max(0, int(time.time() - t))
|
|
74
|
+
for unit, size in (("d", 86400), ("h", 3600), ("min", 60)):
|
|
75
|
+
if s >= size:
|
|
76
|
+
return f"{s // size} {unit} ago"
|
|
77
|
+
return "just now"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def summary(path: str) -> dict:
|
|
81
|
+
state = read_json(Path(path) / STATE)
|
|
82
|
+
if not isinstance(state, dict):
|
|
83
|
+
return {"path": path, "name": Path(path).name, "missing": True}
|
|
84
|
+
steps = state.get("steps") or {}
|
|
85
|
+
if steps.get("ready") == "done":
|
|
86
|
+
badge = ("done", "ready")
|
|
87
|
+
elif steps.get("ready") == "failed":
|
|
88
|
+
badge = ("fail", "not yet")
|
|
89
|
+
elif steps.get("build") == "waiting":
|
|
90
|
+
badge = ("wait", "question")
|
|
91
|
+
elif steps.get("build") in ("running", "paused", "done"):
|
|
92
|
+
badge = ("run", "building")
|
|
93
|
+
elif steps.get("study") == "done":
|
|
94
|
+
badge = ("run", "studied")
|
|
95
|
+
else:
|
|
96
|
+
badge = ("todo", "new")
|
|
97
|
+
return {
|
|
98
|
+
"path": path,
|
|
99
|
+
"name": str(state.get("name") or Path(path).name),
|
|
100
|
+
"request": str(state.get("request", "")),
|
|
101
|
+
"kind": str(state.get("kind", "")),
|
|
102
|
+
"stack": str(state.get("stack", "")),
|
|
103
|
+
"badge": badge,
|
|
104
|
+
"missing": False,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def nearby() -> list[str]:
|
|
109
|
+
"""Projects in the current folder and the projects folder that the list doesn't know yet."""
|
|
110
|
+
found = []
|
|
111
|
+
for base in {Path.cwd(), projects_dir()}:
|
|
112
|
+
try:
|
|
113
|
+
for child in base.iterdir():
|
|
114
|
+
if (child / STATE).is_file():
|
|
115
|
+
found.append(str(child.resolve()))
|
|
116
|
+
except OSError:
|
|
117
|
+
continue
|
|
118
|
+
if (Path.cwd() / STATE).is_file():
|
|
119
|
+
found.append(str(Path.cwd().resolve()))
|
|
120
|
+
return found
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- the screen -------------------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
class Home:
|
|
126
|
+
def __init__(self, ui: UI, loop: Callable, show_status: Callable):
|
|
127
|
+
self.ui = ui
|
|
128
|
+
self.loop = loop # cli.loop(project, ui, message, agent)
|
|
129
|
+
self.show_status = show_status
|
|
130
|
+
self.out = ui.out
|
|
131
|
+
|
|
132
|
+
def w(self, text: str) -> None:
|
|
133
|
+
self.out.write(text)
|
|
134
|
+
self.out.flush()
|
|
135
|
+
|
|
136
|
+
def line(self, text: str = "") -> None:
|
|
137
|
+
self.w(text + "\n")
|
|
138
|
+
|
|
139
|
+
def title(self, describe: bool = False) -> None:
|
|
140
|
+
self.w(CLEAR)
|
|
141
|
+
self.line()
|
|
142
|
+
for row in banner.render(self.ui.width(), self.ui.fancy):
|
|
143
|
+
self.line(row)
|
|
144
|
+
self.line(" " + (banner.gradient(f"study → build → check → refine · v{__version__}") if self.ui.fancy else f"study → build → check → refine · v{__version__}"))
|
|
145
|
+
self.line()
|
|
146
|
+
if describe:
|
|
147
|
+
self.describe()
|
|
148
|
+
|
|
149
|
+
def describe(self) -> None:
|
|
150
|
+
"""What the tool does, in a few lines, on the home screen."""
|
|
151
|
+
ui = self.ui
|
|
152
|
+
rows = shutil.get_terminal_size((80, 24)).lines
|
|
153
|
+
if rows < 30 or ui.width() < 70: # a small window keeps one line, so the list stays visible
|
|
154
|
+
self.line(ui.dim(" Rebuilds any software with your coding agent, checked against the original."))
|
|
155
|
+
self.line()
|
|
156
|
+
return
|
|
157
|
+
self.line(" Recreate a website, CLI, app or game, or build a new one, with your coding agent,")
|
|
158
|
+
self.line(" and prove the result with real checks. Four steps, run for you:")
|
|
159
|
+
self.line()
|
|
160
|
+
for i, (name, text) in enumerate(DESCRIPTION):
|
|
161
|
+
label = f"{i + 1} {name:<7}"
|
|
162
|
+
if ui.fancy:
|
|
163
|
+
label = f"\033[3m\033[1m{banner.fg(banner.colour_at(0.1 + i * 0.22))}{label}\033[0m"
|
|
164
|
+
self.line(f" {label} {ui.dim(text)}")
|
|
165
|
+
self.line()
|
|
166
|
+
|
|
167
|
+
def menu(self, draw_top: Callable[[], None], items: list[str], footer: str, start: int = 0, keys: str = "") -> tuple[str, int]:
|
|
168
|
+
"""Draw a list and let the person move through it. Returns (key, index): key is "enter",
|
|
169
|
+
"esc", or one of `keys`."""
|
|
170
|
+
i = min(max(0, start), max(0, len(items) - 1))
|
|
171
|
+
while True:
|
|
172
|
+
draw_top()
|
|
173
|
+
rows = shutil.get_terminal_size((80, 24)).lines
|
|
174
|
+
room = max(3, rows - (22 if rows >= 30 else 15))
|
|
175
|
+
first = min(max(0, i - room // 2), max(0, len(items) - room))
|
|
176
|
+
if first > 0:
|
|
177
|
+
self.line(self.ui.dim(" ↑ more"))
|
|
178
|
+
for k in range(first, min(len(items), first + room)):
|
|
179
|
+
if k == i:
|
|
180
|
+
self.line(" " + banner.gradient("›", italic=False, bold=True) + " " + self.ui.bold(items[k]) if self.ui.fancy else f" > {items[k]}")
|
|
181
|
+
else:
|
|
182
|
+
self.line(f" {items[k]}")
|
|
183
|
+
if first + room < len(items):
|
|
184
|
+
self.line(self.ui.dim(" ↓ more"))
|
|
185
|
+
self.line()
|
|
186
|
+
self.line(self.ui.dim(f" {footer}"))
|
|
187
|
+
key = read_key()
|
|
188
|
+
if key in ("up", "k") and items:
|
|
189
|
+
i = (i - 1) % len(items)
|
|
190
|
+
elif key in ("down", "j") and items:
|
|
191
|
+
i = (i + 1) % len(items)
|
|
192
|
+
elif key in ("enter", "right") and items:
|
|
193
|
+
return "enter", i
|
|
194
|
+
elif key in ("esc", "q", "left"):
|
|
195
|
+
return "esc", i
|
|
196
|
+
elif key and key in keys:
|
|
197
|
+
return key, i
|
|
198
|
+
|
|
199
|
+
# actions run in the normal screen, so their output stays in the terminal's history
|
|
200
|
+
def outside(self, action: Callable[[], object]) -> None:
|
|
201
|
+
self.w(ALT_OFF + SHOW)
|
|
202
|
+
pause = True
|
|
203
|
+
try:
|
|
204
|
+
pause = action() is not False
|
|
205
|
+
except CraftError as e:
|
|
206
|
+
self.ui.error(e.message)
|
|
207
|
+
if e.fix:
|
|
208
|
+
self.ui.say(f" {self.ui.dim(e.fix)}")
|
|
209
|
+
except KeyboardInterrupt:
|
|
210
|
+
self.ui.say(f"\n {self.ui.dim('stopped')}")
|
|
211
|
+
if pause:
|
|
212
|
+
self.ui.say("")
|
|
213
|
+
self.ui.ask(self.ui.dim("press Enter to go back"))
|
|
214
|
+
self.w(ALT_ON + HIDE)
|
|
215
|
+
|
|
216
|
+
def run(self) -> int:
|
|
217
|
+
self.w(ALT_ON + HIDE)
|
|
218
|
+
try:
|
|
219
|
+
self.projects()
|
|
220
|
+
except KeyboardInterrupt:
|
|
221
|
+
pass
|
|
222
|
+
finally:
|
|
223
|
+
self.w(ALT_OFF + SHOW)
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
def projects(self) -> None:
|
|
227
|
+
known = [i["path"] for i in remembered()]
|
|
228
|
+
for path in nearby():
|
|
229
|
+
if path not in known:
|
|
230
|
+
known.append(path)
|
|
231
|
+
sel = 0
|
|
232
|
+
while True:
|
|
233
|
+
entries = [summary(p) for p in known]
|
|
234
|
+
opened = {i["path"]: i.get("opened", "") for i in remembered()}
|
|
235
|
+
width = self.ui.width()
|
|
236
|
+
labels = [self.ui.c("33", "+ New project")]
|
|
237
|
+
for e in entries:
|
|
238
|
+
if e["missing"]:
|
|
239
|
+
labels.append(self.ui.dim(f"{e['name'][:28]:<28} folder not found"))
|
|
240
|
+
continue
|
|
241
|
+
icon, code = {"done": ("✓", "32"), "fail": ("✗", "31"), "wait": ("?", "33"), "run": ("●", "36"), "todo": ("○", "2")}[e["badge"][0]]
|
|
242
|
+
meta = f"{e['kind']} · {e['stack']}"
|
|
243
|
+
label = f"{e['name'][:28]:<28} {self.ui.c(code, icon)} {e['badge'][1]:<9} {self.ui.dim(f'{meta[:26]:<26}')}"
|
|
244
|
+
when = ago(opened.get(e["path"], ""))
|
|
245
|
+
if when and width >= 90:
|
|
246
|
+
label += " " + self.ui.dim(when)
|
|
247
|
+
labels.append(label)
|
|
248
|
+
|
|
249
|
+
def top() -> None:
|
|
250
|
+
self.title(describe=True)
|
|
251
|
+
self.line(self.ui.bold(" Your creations") + self.ui.dim(f" ({len(entries)})") if entries else self.ui.bold(" Nothing made yet: start with a new project"))
|
|
252
|
+
self.line()
|
|
253
|
+
|
|
254
|
+
key, sel = self.menu(top, labels, "↑↓ move · ⏎ open · n new · x remove from list · q quit", sel, keys="nx")
|
|
255
|
+
if key == "esc":
|
|
256
|
+
return
|
|
257
|
+
if key == "n" or (key == "enter" and sel == 0):
|
|
258
|
+
made = self.new_project()
|
|
259
|
+
if made:
|
|
260
|
+
known = [made, *[k for k in known if k != made]]
|
|
261
|
+
sel = 1
|
|
262
|
+
self.project(made)
|
|
263
|
+
continue
|
|
264
|
+
if sel == 0:
|
|
265
|
+
continue
|
|
266
|
+
entry = entries[sel - 1]
|
|
267
|
+
if key == "x":
|
|
268
|
+
forget(entry["path"])
|
|
269
|
+
known.remove(entry["path"])
|
|
270
|
+
sel = max(0, sel - 1)
|
|
271
|
+
elif key == "enter" and not entry["missing"]:
|
|
272
|
+
self.project(entry["path"])
|
|
273
|
+
|
|
274
|
+
def project(self, path: str) -> None:
|
|
275
|
+
sel = 0
|
|
276
|
+
while True:
|
|
277
|
+
try:
|
|
278
|
+
p = Project.open(Path(path))
|
|
279
|
+
except CraftError:
|
|
280
|
+
return
|
|
281
|
+
actions = []
|
|
282
|
+
if p.pending_questions():
|
|
283
|
+
actions.append(("answer", "Answer the builder's questions"))
|
|
284
|
+
ready = p.state["steps"].get("ready") == "done"
|
|
285
|
+
actions.append(("run", "Continue building" if not ready else "Build again"))
|
|
286
|
+
actions += [("change", "Ask for a change…"), ("open", "Open the result"), ("check", "Check it again"), ("folder", "Show the folder"), ("back", "Back")]
|
|
287
|
+
|
|
288
|
+
def top() -> None:
|
|
289
|
+
self.title()
|
|
290
|
+
self.show_status(p, self.ui)
|
|
291
|
+
self.line()
|
|
292
|
+
|
|
293
|
+
key, sel = self.menu(top, [a[1] for a in actions], "↑↓ move · ⏎ choose · q back", sel)
|
|
294
|
+
if key == "esc" or actions[sel][0] == "back":
|
|
295
|
+
return
|
|
296
|
+
self.act(p, actions[sel][0])
|
|
297
|
+
|
|
298
|
+
def act(self, p: Project, what: str) -> None:
|
|
299
|
+
from . import cli # the same commands the CLI runs
|
|
300
|
+
|
|
301
|
+
ui = self.ui
|
|
302
|
+
if what == "run":
|
|
303
|
+
self.outside(lambda: (cli.show_header(p, ui), self.loop(p, ui, None, None)))
|
|
304
|
+
elif what == "change":
|
|
305
|
+
def change() -> None:
|
|
306
|
+
msg = ui.ask("What should change?")
|
|
307
|
+
if msg:
|
|
308
|
+
cli.show_header(p, ui)
|
|
309
|
+
self.loop(p, ui, msg, None)
|
|
310
|
+
self.outside(change)
|
|
311
|
+
elif what == "answer":
|
|
312
|
+
self.outside(lambda: cli.ask_questions(p, ui))
|
|
313
|
+
elif what == "check":
|
|
314
|
+
self.outside(lambda: (cli.show_header(p, ui), cli.do_check(p, ui)))
|
|
315
|
+
elif what == "open":
|
|
316
|
+
self.outside(lambda: cli.open_result(p, ui))
|
|
317
|
+
elif what == "folder":
|
|
318
|
+
opener = {"darwin": ["open"], "win32": ["explorer"]}.get(sys.platform, ["xdg-open"])
|
|
319
|
+
r = proc.run([*opener, str(p.root)], timeout=15)
|
|
320
|
+
if not r.ok and sys.platform != "win32": # explorer exits 1 even when it worked
|
|
321
|
+
self.outside(lambda: ui.say(f" {p.root}"))
|
|
322
|
+
|
|
323
|
+
def new_project(self) -> Optional[str]:
|
|
324
|
+
"""A few questions in the normal screen, then the project is made in the projects folder."""
|
|
325
|
+
result: dict = {}
|
|
326
|
+
|
|
327
|
+
def ask() -> bool:
|
|
328
|
+
ui = self.ui
|
|
329
|
+
self.w(CLEAR)
|
|
330
|
+
for row in banner.render(ui.width(), ui.fancy):
|
|
331
|
+
ui.say(row)
|
|
332
|
+
ui.say("")
|
|
333
|
+
ui.say(ui.bold(" New project") + ui.dim(f" (in {projects_dir()}, empty answer to cancel)"))
|
|
334
|
+
ui.say("")
|
|
335
|
+
request = ui.ask("What do you want to make?")
|
|
336
|
+
if not request:
|
|
337
|
+
return False
|
|
338
|
+
while True:
|
|
339
|
+
raw = ui.ask("Something to recreate? web address, file, folder or command (Enter for none):")
|
|
340
|
+
try:
|
|
341
|
+
target = parse_target(raw)
|
|
342
|
+
break
|
|
343
|
+
except CraftError as e:
|
|
344
|
+
ui.error(e.message)
|
|
345
|
+
ui.say(f" {ui.dim(e.fix)}")
|
|
346
|
+
deep = ui.choose("Mode?", ["Clean room: only what a visitor or user sees", "Deep: look inside the original (private use)"]).startswith("Deep")
|
|
347
|
+
allow_run = False
|
|
348
|
+
if target["type"] == "local" and os.access(target["value"], os.X_OK) and Path(target["value"]).is_file():
|
|
349
|
+
allow_run = ui.ask("Run it to record what it prints? [y/N]").lower() in ("y", "yes", "o", "oui")
|
|
350
|
+
folder = projects_dir() / slug(request)
|
|
351
|
+
n = 2
|
|
352
|
+
while folder.exists():
|
|
353
|
+
folder = projects_dir() / f"{slug(request)}-{n}"
|
|
354
|
+
n += 1
|
|
355
|
+
kind = tables.detect_kind(request, target["type"])
|
|
356
|
+
stack = tables.detect_stack(request)["name"]
|
|
357
|
+
p = Project.create(folder, target, request, kind, stack, deep, allow_run)
|
|
358
|
+
ui.step("done", "new", f"{folder} · {kind} · {stack} · {p.mode()}")
|
|
359
|
+
result["path"] = str(folder)
|
|
360
|
+
if ui.choose("Start building now?", ["Yes, start", "Later"]).startswith("Yes"):
|
|
361
|
+
ui.say("")
|
|
362
|
+
from . import cli
|
|
363
|
+
|
|
364
|
+
cli.show_header(p, ui)
|
|
365
|
+
self.loop(p, ui, None, None)
|
|
366
|
+
return True
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
self.outside(ask)
|
|
370
|
+
return result.get("path")
|
craft_kit/png.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Comparing screenshots without dependencies: a small PNG reader (8-bit, non-interlaced, the kind
|
|
2
|
+
browsers write) and a grid similarity. Uses Pillow instead when it's installed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import struct
|
|
7
|
+
import zlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
MAX_ROWS = 2400 # compare the top of long pages; enough for the layout, quick in pure Python
|
|
12
|
+
COLS = 64
|
|
13
|
+
TOLERANCE = 32 # a cell matches when no channel differs by more than this
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Grid:
|
|
17
|
+
def __init__(self, width: int, height: int, cells: list[list[tuple[int, int, int]]]):
|
|
18
|
+
self.width, self.height, self.cells = width, height, cells
|
|
19
|
+
|
|
20
|
+
def blank(self) -> bool:
|
|
21
|
+
first = self.cells[0][0] if self.cells and self.cells[0] else None
|
|
22
|
+
return all(c == first for row in self.cells for c in row)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _paeth(a: int, b: int, c: int) -> int:
|
|
26
|
+
p = a + b - c
|
|
27
|
+
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
28
|
+
return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _grid_pure(path: Path) -> Optional[Grid]:
|
|
32
|
+
data = path.read_bytes()
|
|
33
|
+
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
34
|
+
return None
|
|
35
|
+
pos, idat, palette = 8, [], b""
|
|
36
|
+
width = height = depth = ctype = interlace = 0
|
|
37
|
+
while pos + 8 <= len(data):
|
|
38
|
+
length, kind = struct.unpack(">I4s", data[pos : pos + 8])
|
|
39
|
+
body = data[pos + 8 : pos + 8 + length]
|
|
40
|
+
pos += 12 + length
|
|
41
|
+
if kind == b"IHDR" and len(body) >= 13:
|
|
42
|
+
width, height, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body[:13])
|
|
43
|
+
elif kind == b"PLTE":
|
|
44
|
+
palette = body
|
|
45
|
+
elif kind == b"IDAT":
|
|
46
|
+
idat.append(body)
|
|
47
|
+
elif kind == b"IEND":
|
|
48
|
+
break
|
|
49
|
+
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(ctype)
|
|
50
|
+
if depth != 8 or interlace or not channels or width <= 0 or height <= 0 or width > 20_000:
|
|
51
|
+
return None
|
|
52
|
+
rows = min(height, MAX_ROWS)
|
|
53
|
+
stride = width * channels
|
|
54
|
+
raw = zlib.decompressobj().decompress(b"".join(idat), (stride + 1) * rows)
|
|
55
|
+
step = max(1, width // COLS)
|
|
56
|
+
grid_rows = max(1, rows * COLS // width) if width >= COLS else rows
|
|
57
|
+
want = {min(rows - 1, int((r + 0.5) * rows / grid_rows)) for r in range(grid_rows)}
|
|
58
|
+
cols = [min(width - 1, c * step + step // 2) for c in range(min(COLS, width))]
|
|
59
|
+
prev = bytearray(stride)
|
|
60
|
+
cells: list[list[tuple[int, int, int]]] = []
|
|
61
|
+
for y in range(rows):
|
|
62
|
+
start = y * (stride + 1)
|
|
63
|
+
if start + stride + 1 > len(raw):
|
|
64
|
+
break
|
|
65
|
+
f = raw[start]
|
|
66
|
+
line = bytearray(raw[start + 1 : start + 1 + stride])
|
|
67
|
+
if f == 1:
|
|
68
|
+
for i in range(channels, stride):
|
|
69
|
+
line[i] = (line[i] + line[i - channels]) & 255
|
|
70
|
+
elif f == 2:
|
|
71
|
+
for i in range(stride):
|
|
72
|
+
line[i] = (line[i] + prev[i]) & 255
|
|
73
|
+
elif f == 3:
|
|
74
|
+
for i in range(stride):
|
|
75
|
+
left = line[i - channels] if i >= channels else 0
|
|
76
|
+
line[i] = (line[i] + ((left + prev[i]) >> 1)) & 255
|
|
77
|
+
elif f == 4:
|
|
78
|
+
for i in range(stride):
|
|
79
|
+
left = line[i - channels] if i >= channels else 0
|
|
80
|
+
up_left = prev[i - channels] if i >= channels else 0
|
|
81
|
+
line[i] = (line[i] + _paeth(left, prev[i], up_left)) & 255
|
|
82
|
+
if y in want:
|
|
83
|
+
row = []
|
|
84
|
+
for x in cols:
|
|
85
|
+
o = x * channels
|
|
86
|
+
if ctype == 3:
|
|
87
|
+
k = line[o] * 3
|
|
88
|
+
px = tuple(palette[k : k + 3]) if k + 3 <= len(palette) else (0, 0, 0)
|
|
89
|
+
elif channels <= 2:
|
|
90
|
+
px = (line[o],) * 3
|
|
91
|
+
else:
|
|
92
|
+
px = (line[o], line[o + 1], line[o + 2])
|
|
93
|
+
row.append(px) # type: ignore[arg-type]
|
|
94
|
+
cells.append(row)
|
|
95
|
+
prev = line
|
|
96
|
+
return Grid(width, height, cells) if cells else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _grid_pillow(path: Path) -> Optional[Grid]:
|
|
100
|
+
try:
|
|
101
|
+
from PIL import Image # type: ignore
|
|
102
|
+
except ImportError:
|
|
103
|
+
return None
|
|
104
|
+
with Image.open(path) as im:
|
|
105
|
+
im = im.convert("RGB")
|
|
106
|
+
w, h = im.size
|
|
107
|
+
h2 = min(h, MAX_ROWS)
|
|
108
|
+
im = im.crop((0, 0, w, h2))
|
|
109
|
+
cols = min(COLS, w)
|
|
110
|
+
rows = max(1, h2 * cols // w)
|
|
111
|
+
small = im.resize((cols, rows))
|
|
112
|
+
px = small.load()
|
|
113
|
+
return Grid(w, h, [[px[x, y] for x in range(cols)] for y in range(rows)])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def grid(path: Path) -> Optional[Grid]:
|
|
117
|
+
try:
|
|
118
|
+
return _grid_pillow(path) or _grid_pure(path)
|
|
119
|
+
except (OSError, ValueError, zlib.error, struct.error):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def similarity(a: Grid, b: Grid) -> float:
|
|
124
|
+
"""Percent of grid cells that match; rows only one image has count as different."""
|
|
125
|
+
rows = max(len(a.cells), len(b.cells))
|
|
126
|
+
cols = min(len(a.cells[0]), len(b.cells[0])) if a.cells and b.cells else 0
|
|
127
|
+
if rows == 0 or cols == 0:
|
|
128
|
+
return 0.0
|
|
129
|
+
same = 0
|
|
130
|
+
for y in range(min(len(a.cells), len(b.cells))):
|
|
131
|
+
for x in range(cols):
|
|
132
|
+
pa, pb = a.cells[y][x], b.cells[y][x]
|
|
133
|
+
if max(abs(pa[0] - pb[0]), abs(pa[1] - pb[1]), abs(pa[2] - pb[2])) <= TOLERANCE:
|
|
134
|
+
same += 1
|
|
135
|
+
return 100.0 * same / (rows * cols)
|
craft_kit/proc.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Running other programs without ever hanging: stdin closed or piped, wall-clock and idle deadlines,
|
|
2
|
+
output drained on a thread and capped, the whole process group killed on timeout or Ctrl-C."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Callable, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
WINDOWS = os.name == "nt"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Result:
|
|
21
|
+
code: Optional[int]
|
|
22
|
+
output: str = ""
|
|
23
|
+
timed_out: bool = False
|
|
24
|
+
error: str = "" # why it couldn't start or was stopped, in plain words
|
|
25
|
+
lines: int = 0
|
|
26
|
+
meta: dict = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def ok(self) -> bool:
|
|
30
|
+
return self.code == 0 and not self.timed_out and not self.error
|
|
31
|
+
|
|
32
|
+
def tail(self, n: int = 30) -> str:
|
|
33
|
+
return "\n".join(self.output.rstrip().splitlines()[-n:])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def which(program: str) -> Optional[str]:
|
|
37
|
+
return shutil.which(program)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _kill(p: subprocess.Popen) -> None:
|
|
41
|
+
try:
|
|
42
|
+
if WINDOWS:
|
|
43
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(p.pid)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
|
44
|
+
else:
|
|
45
|
+
os.killpg(p.pid, signal.SIGTERM)
|
|
46
|
+
try:
|
|
47
|
+
p.wait(timeout=3)
|
|
48
|
+
return
|
|
49
|
+
except subprocess.TimeoutExpired:
|
|
50
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
51
|
+
except (OSError, subprocess.SubprocessError):
|
|
52
|
+
pass
|
|
53
|
+
try:
|
|
54
|
+
p.kill()
|
|
55
|
+
p.wait(timeout=3)
|
|
56
|
+
except (OSError, subprocess.SubprocessError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run(
|
|
61
|
+
argv: Sequence[str],
|
|
62
|
+
cwd: Optional[str] = None,
|
|
63
|
+
timeout: float = 600,
|
|
64
|
+
idle: Optional[float] = None,
|
|
65
|
+
input_text: Optional[str] = None,
|
|
66
|
+
on_line: Optional[Callable[[str], None]] = None,
|
|
67
|
+
cap: int = 400_000,
|
|
68
|
+
env: Optional[dict] = None,
|
|
69
|
+
) -> Result:
|
|
70
|
+
if not argv:
|
|
71
|
+
return Result(None, error="nothing to run")
|
|
72
|
+
exe = which(argv[0]) or argv[0]
|
|
73
|
+
if not os.path.isabs(exe) and os.sep not in exe and not which(exe):
|
|
74
|
+
return Result(None, error=f"`{argv[0]}` isn't installed or isn't on your PATH")
|
|
75
|
+
kwargs: dict = {}
|
|
76
|
+
if WINDOWS:
|
|
77
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
78
|
+
else:
|
|
79
|
+
kwargs["start_new_session"] = True
|
|
80
|
+
try:
|
|
81
|
+
p = subprocess.Popen(
|
|
82
|
+
[exe, *argv[1:]],
|
|
83
|
+
cwd=cwd,
|
|
84
|
+
stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL,
|
|
85
|
+
stdout=subprocess.PIPE,
|
|
86
|
+
stderr=subprocess.STDOUT,
|
|
87
|
+
env={**os.environ, **(env or {})},
|
|
88
|
+
**kwargs,
|
|
89
|
+
)
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
return Result(None, error=f"`{argv[0]}` isn't installed or isn't on your PATH")
|
|
92
|
+
except OSError as e:
|
|
93
|
+
return Result(None, error=f"`{argv[0]}` couldn't start: {e}")
|
|
94
|
+
|
|
95
|
+
lines: "queue.Queue[Optional[str]]" = queue.Queue()
|
|
96
|
+
|
|
97
|
+
def feed() -> None:
|
|
98
|
+
try:
|
|
99
|
+
assert p.stdin is not None
|
|
100
|
+
p.stdin.write(input_text.encode("utf-8", "replace")) # type: ignore[union-attr]
|
|
101
|
+
p.stdin.close()
|
|
102
|
+
except (OSError, ValueError):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
def drain() -> None:
|
|
106
|
+
try:
|
|
107
|
+
assert p.stdout is not None
|
|
108
|
+
for raw in iter(p.stdout.readline, b""):
|
|
109
|
+
lines.put(raw.decode("utf-8", "replace"))
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
pass
|
|
112
|
+
finally:
|
|
113
|
+
lines.put(None)
|
|
114
|
+
|
|
115
|
+
if input_text is not None:
|
|
116
|
+
threading.Thread(target=feed, daemon=True).start()
|
|
117
|
+
threading.Thread(target=drain, daemon=True).start()
|
|
118
|
+
|
|
119
|
+
out: list[str] = []
|
|
120
|
+
size = 0
|
|
121
|
+
count = 0
|
|
122
|
+
start = last = time.monotonic()
|
|
123
|
+
timed_out = False
|
|
124
|
+
error = ""
|
|
125
|
+
try:
|
|
126
|
+
while True:
|
|
127
|
+
try:
|
|
128
|
+
line = lines.get(timeout=0.25)
|
|
129
|
+
except queue.Empty:
|
|
130
|
+
now = time.monotonic()
|
|
131
|
+
if now - start > timeout:
|
|
132
|
+
timed_out, error = True, f"stopped after {int(timeout)} s (time limit)"
|
|
133
|
+
break
|
|
134
|
+
if idle is not None and now - last > idle:
|
|
135
|
+
timed_out, error = True, f"stopped after {int(idle)} s with no output"
|
|
136
|
+
break
|
|
137
|
+
continue
|
|
138
|
+
if line is None:
|
|
139
|
+
break
|
|
140
|
+
last = time.monotonic()
|
|
141
|
+
count += 1
|
|
142
|
+
out.append(line)
|
|
143
|
+
size += len(line)
|
|
144
|
+
while size > cap and len(out) > 1:
|
|
145
|
+
size -= len(out.pop(0))
|
|
146
|
+
if on_line is not None:
|
|
147
|
+
try:
|
|
148
|
+
on_line(line.rstrip("\n"))
|
|
149
|
+
except Exception: # a display callback must never stop the run
|
|
150
|
+
pass
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
_kill(p)
|
|
153
|
+
raise
|
|
154
|
+
if timed_out:
|
|
155
|
+
_kill(p)
|
|
156
|
+
try:
|
|
157
|
+
code = p.wait(timeout=10)
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
_kill(p)
|
|
160
|
+
code = p.poll()
|
|
161
|
+
return Result(None if timed_out else code, "".join(out), timed_out, error, count)
|