shotlist 0.1.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.
capture/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """capture — reproducible screenshot capture for docs.
2
+
3
+ Drive a web app or CLI from a declarative shot list (``.capture.yaml``) and
4
+ capture polished, repeatable feature screenshots for READMEs, posts, and
5
+ test-evidence documents.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Capture backends: one rendering engine (Playwright/Chromium), two inputs.
2
+
3
+ - :mod:`capture.backends.web` screenshots live web pages.
4
+ - :mod:`capture.backends.cli` renders command output as a styled terminal window
5
+ and screenshots that, so CLI docs match the web ones visually.
6
+ """
@@ -0,0 +1,96 @@
1
+ """Capture CLI output as a styled terminal-window screenshot.
2
+
3
+ The command runs under a pseudo-terminal so tools emit their normal colors, the
4
+ output is converted to HTML, rendered as a terminal card (:mod:`capture.render`),
5
+ and screenshotted with Playwright.
6
+ """
7
+
8
+ import contextlib
9
+ import fcntl
10
+ import os
11
+ import pty
12
+ import select
13
+ import signal
14
+ import struct
15
+ import subprocess
16
+ import termios
17
+ import time
18
+
19
+ from playwright.sync_api import Page
20
+
21
+ from capture.config import CliShot
22
+ from capture.render import ansi_to_html, terminal_html
23
+
24
+
25
+ def _set_winsize(fd: int, cols: int, rows: int = 50) -> None:
26
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
27
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
28
+
29
+
30
+ def run_command(
31
+ command: str,
32
+ cwd: str | None,
33
+ cols: int,
34
+ timeout: float = 60.0,
35
+ ) -> str:
36
+ """Run ``command`` under a PTY and return its raw (ANSI) output.
37
+
38
+ A PTY makes tools emit colors as if attached to a real terminal. The command
39
+ is killed if it does not finish within ``timeout`` so capture never hangs.
40
+ """
41
+ master, slave = pty.openpty()
42
+ _set_winsize(slave, cols)
43
+ env = {
44
+ **os.environ,
45
+ "TERM": "xterm-256color",
46
+ "COLUMNS": str(cols),
47
+ "FORCE_COLOR": "1",
48
+ "CLICOLOR_FORCE": "1",
49
+ }
50
+ proc = subprocess.Popen(
51
+ command,
52
+ shell=True,
53
+ cwd=cwd,
54
+ stdin=slave,
55
+ stdout=slave,
56
+ stderr=slave,
57
+ env=env,
58
+ start_new_session=True,
59
+ close_fds=True,
60
+ )
61
+ os.close(slave)
62
+
63
+ chunks: list[bytes] = []
64
+ deadline = time.monotonic() + timeout
65
+ try:
66
+ while time.monotonic() < deadline:
67
+ ready, _, _ = select.select([master], [], [], 0.2)
68
+ if not ready:
69
+ continue
70
+ try:
71
+ data = os.read(master, 4096)
72
+ except OSError:
73
+ break # slave closed — child finished
74
+ if not data:
75
+ break
76
+ chunks.append(data)
77
+ finally:
78
+ if proc.poll() is None:
79
+ with contextlib.suppress(ProcessLookupError):
80
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
81
+ proc.wait()
82
+ os.close(master)
83
+
84
+ return b"".join(chunks).decode(errors="replace")
85
+
86
+
87
+ def capture_cli(page: Page, shot: CliShot, cwd: str | None = None) -> bytes:
88
+ """Run the shot's command and return a PNG of its rendered terminal output.
89
+
90
+ ``cwd`` overrides ``shot.cwd`` when given (the engine passes the working
91
+ directory resolved relative to the repo root).
92
+ """
93
+ working_dir = cwd if cwd is not None else shot.cwd
94
+ raw = run_command(shot.command, working_dir, shot.cols)
95
+ page.set_content(terminal_html(ansi_to_html(raw), shot.cols))
96
+ return page.locator(".frame").screenshot()
@@ -0,0 +1,234 @@
1
+ """Capture a *real* macOS Terminal window — an authentic screenshot.
2
+
3
+ Unlike the rendered CLI backend (which recreates output as styled HTML), this
4
+ drives the actual Terminal.app via AppleScript: open a window, size it, run the
5
+ command after a ``clear``, wait for it to finish, then ``screencapture`` the live
6
+ window and close it — all atomically so nothing can slide in front mid-capture.
7
+
8
+ Requires macOS and Screen-Recording permission for the controlling terminal
9
+ (System Settings → Privacy & Security → Screen Recording).
10
+ """
11
+
12
+ import contextlib
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ # AppleScript args: cwd, command, cols, rows, out_path
19
+ _APPLESCRIPT = r'''on run argv
20
+ set theCwd to item 1 of argv
21
+ set theCmd to item 2 of argv
22
+ set theCols to (item 3 of argv) as integer
23
+ set theRows to (item 4 of argv) as integer
24
+ set outPath to item 5 of argv
25
+ tell application "Terminal"
26
+ activate
27
+ set t to do script ""
28
+ delay 0.4
29
+ set w to front window
30
+ set number of columns of w to theCols
31
+ set number of rows of w to theRows
32
+ delay 0.2
33
+ do script ("cd " & quoted form of theCwd & " && clear && " & theCmd) in t
34
+ delay 0.4
35
+ set tries to 0
36
+ repeat while (busy of t) and (tries < 600)
37
+ delay 0.2
38
+ set tries to tries + 1
39
+ end repeat
40
+ delay 0.5
41
+ -- Capture by WINDOW ID, not screen region: a region grab photographs whatever
42
+ -- pixels are stacked on top (e.g. the user's own frontmost terminal), leaking
43
+ -- unrelated window content into the shot. -l targets this window regardless of
44
+ -- stacking and doesn't require it to be frontmost.
45
+ set wid to id of w
46
+ do shell script "screencapture -x -o -l " & wid & " " & quoted form of outPath
47
+ close w saving no
48
+ end tell
49
+ end run
50
+ '''
51
+
52
+
53
+ class NativeCaptureError(RuntimeError):
54
+ """Raised when a real Terminal capture cannot be performed."""
55
+
56
+
57
+ def capture_terminal(
58
+ command: str,
59
+ cwd: str,
60
+ cols: int,
61
+ rows: int,
62
+ timeout: float = 120.0,
63
+ ) -> bytes:
64
+ """Run ``command`` in a real Terminal window and return PNG bytes of it."""
65
+ if sys.platform != "darwin":
66
+ raise NativeCaptureError(
67
+ "native terminal capture requires macOS; set 'style: rendered' on this platform"
68
+ )
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ script_path = Path(tmp) / "capture.applescript"
71
+ out_path = Path(tmp) / "shot.png"
72
+ script_path.write_text(_APPLESCRIPT)
73
+ try:
74
+ subprocess.run(
75
+ [
76
+ "osascript",
77
+ str(script_path),
78
+ cwd,
79
+ command,
80
+ str(cols),
81
+ str(rows),
82
+ str(out_path),
83
+ ],
84
+ check=True,
85
+ capture_output=True,
86
+ text=True,
87
+ timeout=timeout,
88
+ )
89
+ except subprocess.CalledProcessError as exc:
90
+ raise NativeCaptureError(
91
+ "Terminal capture failed. Grant Screen Recording permission to your "
92
+ "terminal in System Settings → Privacy & Security → Screen Recording, "
93
+ f"then retry.\n{exc.stderr.strip()}"
94
+ ) from exc
95
+ except subprocess.TimeoutExpired as exc:
96
+ raise NativeCaptureError(
97
+ f"Terminal capture timed out after {timeout:.0f}s running: {command}"
98
+ ) from exc
99
+ if not out_path.exists():
100
+ raise NativeCaptureError("Terminal capture produced no image")
101
+ return out_path.read_bytes()
102
+
103
+
104
+ # --- Persistent sessions: one window, many commands, a screenshot after each ---
105
+
106
+ # args: cwd, cols, rows -> prints the Terminal window id
107
+ _CREATE_SCRIPT = r'''on run argv
108
+ set theCwd to item 1 of argv
109
+ set theCols to (item 2 of argv) as integer
110
+ set theRows to (item 3 of argv) as integer
111
+ tell application "Terminal"
112
+ activate
113
+ set t to do script ""
114
+ delay 0.4
115
+ set w to front window
116
+ set number of columns of w to theCols
117
+ set number of rows of w to theRows
118
+ delay 0.2
119
+ do script ("cd " & quoted form of theCwd & " && clear") in t
120
+ delay 0.3
121
+ return (id of w) as text
122
+ end tell
123
+ end run
124
+ '''
125
+
126
+ # args: window_id, command, clear("1"/"0"), wait_ms, out_path
127
+ _STEP_SCRIPT = r'''on run argv
128
+ set wid to (item 1 of argv) as integer
129
+ set theCmd to item 2 of argv
130
+ set doClear to (item 3 of argv) is "1"
131
+ set waitMs to (item 4 of argv) as integer
132
+ set outPath to item 5 of argv
133
+ tell application "Terminal"
134
+ set w to window id wid
135
+ set t to selected tab of w
136
+ if doClear then
137
+ do script "clear" in t
138
+ delay 0.3
139
+ end if
140
+ do script theCmd in t
141
+ delay 0.3
142
+ set tries to 0
143
+ repeat while (busy of t) and (tries < 1500)
144
+ delay 0.2
145
+ set tries to tries + 1
146
+ end repeat
147
+ if waitMs > 0 then
148
+ delay (waitMs / 1000)
149
+ end if
150
+ delay 0.3
151
+ -- Capture by WINDOW ID (see the single-shot script): region grabs leak whatever
152
+ -- window is stacked on top; -l hits this window even when it isn't frontmost.
153
+ do shell script "screencapture -x -o -l " & wid & " " & quoted form of outPath
154
+ end tell
155
+ end run
156
+ '''
157
+
158
+ # args: window_id
159
+ _CLOSE_SCRIPT = r'''on run argv
160
+ set wid to (item 1 of argv) as integer
161
+ tell application "Terminal" to close (window id wid) saving no
162
+ end run
163
+ '''
164
+
165
+
166
+ def _run_osascript(script_body: str, args: list[str], timeout: float) -> str:
167
+ with tempfile.TemporaryDirectory() as tmp:
168
+ path = Path(tmp) / "script.applescript"
169
+ path.write_text(script_body)
170
+ try:
171
+ proc = subprocess.run(
172
+ ["osascript", str(path), *args],
173
+ check=True,
174
+ capture_output=True,
175
+ text=True,
176
+ timeout=timeout,
177
+ )
178
+ except subprocess.CalledProcessError as exc:
179
+ raise NativeCaptureError(
180
+ "Terminal automation failed. Grant Screen Recording permission to your "
181
+ "terminal in System Settings → Privacy & Security → Screen Recording.\n"
182
+ f"{exc.stderr.strip()}"
183
+ ) from exc
184
+ except subprocess.TimeoutExpired as exc:
185
+ raise NativeCaptureError(f"Terminal automation timed out after {timeout:.0f}s") from exc
186
+ return proc.stdout.strip()
187
+
188
+
189
+ def _create_session(cwd: str, cols: int, rows: int) -> str:
190
+ return _run_osascript(_CREATE_SCRIPT, [cwd, str(cols), str(rows)], timeout=60.0)
191
+
192
+
193
+ def _run_step(wid: str, command: str, clear: bool, wait_ms: int, out_path: str) -> None:
194
+ _run_osascript(
195
+ _STEP_SCRIPT,
196
+ [wid, command, "1" if clear else "0", str(wait_ms), out_path],
197
+ timeout=600.0,
198
+ )
199
+
200
+
201
+ def _close_session(wid: str) -> None:
202
+ _run_osascript(_CLOSE_SCRIPT, [wid], timeout=30.0)
203
+
204
+
205
+ def capture_terminal_session(
206
+ steps: list[tuple[str, bool, int]],
207
+ cwd: str,
208
+ cols: int,
209
+ rows: int,
210
+ ) -> list[bytes]:
211
+ """Run ``steps`` in one persistent Terminal window, capturing after each.
212
+
213
+ Each step is ``(command, clear_first, wait_ms)``. The shell state persists
214
+ across steps; the window is captured after every step and closed at the end
215
+ (even on error). Returns one PNG per step, in order.
216
+ """
217
+ if sys.platform != "darwin":
218
+ raise NativeCaptureError(
219
+ "native terminal capture requires macOS; set 'style: rendered' on this platform"
220
+ )
221
+ images: list[bytes] = []
222
+ with tempfile.TemporaryDirectory() as tmp:
223
+ wid = _create_session(cwd, cols, rows)
224
+ try:
225
+ for index, (command, clear, wait_ms) in enumerate(steps):
226
+ out_path = Path(tmp) / f"{index:03d}.png"
227
+ _run_step(wid, command, clear, wait_ms, str(out_path))
228
+ if not out_path.exists():
229
+ raise NativeCaptureError(f"no image produced for step {index + 1}")
230
+ images.append(out_path.read_bytes())
231
+ finally:
232
+ with contextlib.suppress(NativeCaptureError):
233
+ _close_session(wid)
234
+ return images
@@ -0,0 +1,38 @@
1
+ """Capture screenshots of live web pages with Playwright."""
2
+
3
+ from playwright.sync_api import Page
4
+
5
+ from capture.config import Step, WebShot
6
+
7
+
8
+ def apply_step(page: Page, step: Step) -> None:
9
+ """Run one declarative interaction against the page."""
10
+ if step.goto is not None:
11
+ page.goto(step.goto)
12
+ elif step.click is not None:
13
+ page.click(step.click)
14
+ elif step.fill is not None:
15
+ selector, value = step.fill
16
+ page.fill(selector, value)
17
+ elif step.wait_for is not None:
18
+ page.wait_for_selector(step.wait_for)
19
+ elif step.wait_ms is not None:
20
+ page.wait_for_timeout(step.wait_ms)
21
+ elif step.press is not None:
22
+ page.keyboard.press(step.press)
23
+
24
+
25
+ def capture_web(page: Page, shot: WebShot) -> bytes:
26
+ """Navigate, run any interaction steps, and return PNG bytes.
27
+
28
+ Uses Playwright's default ``load`` wait (not ``networkidle``) so apps holding
29
+ open connections — websockets, SSE — do not hang; use a ``wait_for`` step to
30
+ gate on dynamic content instead.
31
+ """
32
+ page.set_viewport_size({"width": shot.viewport.width, "height": shot.viewport.height})
33
+ page.goto(shot.url)
34
+ for step in shot.steps:
35
+ apply_step(page, step)
36
+ if shot.selector is not None:
37
+ return page.locator(shot.selector).screenshot()
38
+ return page.screenshot(full_page=shot.full_page)
capture/check.py ADDED
@@ -0,0 +1,65 @@
1
+ """Compare a fresh capture against a committed baseline manifest (drift check).
2
+
3
+ The committed ``manifest.json`` records a ``sha256`` per shot, so a re-capture can
4
+ be checked against it without any image-diffing dependency. Structural changes
5
+ (a shot added or removed) always count as drift; content changes are only flagged
6
+ for *deterministic* shots — a real Terminal screenshot can't be compared, so it is
7
+ skipped rather than reported as a spurious change.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Literal
12
+
13
+ from capture.report import Manifest
14
+
15
+ Status = Literal["unchanged", "changed", "added", "removed", "skipped"]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ShotDiff:
20
+ """How one shot compares between the baseline and the fresh capture."""
21
+
22
+ name: str
23
+ status: Status
24
+ reason: str = ""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CheckResult:
29
+ """The outcome of comparing a capture against its baseline."""
30
+
31
+ diffs: list[ShotDiff]
32
+
33
+ @property
34
+ def drifted(self) -> bool:
35
+ """True when any shot changed, was added, or was removed."""
36
+ return any(d.status in ("changed", "added", "removed") for d in self.diffs)
37
+
38
+
39
+ def compare_manifests(baseline: Manifest, current: Manifest) -> CheckResult:
40
+ """Compare ``current`` against ``baseline`` per shot, keyed by name."""
41
+ base_by_name = {shot["name"]: shot for shot in baseline["shots"]}
42
+ current_names = {shot["name"] for shot in current["shots"]}
43
+ diffs: list[ShotDiff] = []
44
+
45
+ for shot in current["shots"]:
46
+ name = shot["name"]
47
+ if name not in base_by_name:
48
+ diffs.append(ShotDiff(name, "added"))
49
+ elif not shot["deterministic"]:
50
+ diffs.append(ShotDiff(name, "skipped", "not reproducible (native)"))
51
+ elif base_by_name[name]["sha256"] != shot["sha256"]:
52
+ diffs.append(ShotDiff(name, "changed"))
53
+ else:
54
+ diffs.append(ShotDiff(name, "unchanged"))
55
+
56
+ for shot in baseline["shots"]:
57
+ if shot["name"] not in current_names:
58
+ if shot["deterministic"]:
59
+ diffs.append(ShotDiff(shot["name"], "removed"))
60
+ else:
61
+ # `check` only recaptures deterministic shots, so a native shot
62
+ # missing from the current set was never re-shot — not removed.
63
+ diffs.append(ShotDiff(shot["name"], "skipped", "not reproducible (native)"))
64
+
65
+ return CheckResult(diffs=diffs)