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/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)