bugcap 0.2.2__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.
- bugcap/__init__.py +1 -0
- bugcap/agent_api.py +221 -0
- bugcap/backends.py +133 -0
- bugcap/capture.py +84 -0
- bugcap/cli.py +942 -0
- bugcap/config.py +86 -0
- bugcap/dashboard/__init__.py +3 -0
- bugcap/dashboard/api.py +105 -0
- bugcap/dashboard/media.py +73 -0
- bugcap/dashboard/server.py +213 -0
- bugcap/dashboard/static/app.css +43 -0
- bugcap/dashboard/static/app.js +179 -0
- bugcap/dashboard/static/index.html +45 -0
- bugcap/drafts.py +83 -0
- bugcap/errors.py +30 -0
- bugcap/ghcli.py +175 -0
- bugcap/ingest.py +210 -0
- bugcap/live.py +292 -0
- bugcap/live_session.py +125 -0
- bugcap/logs.py +74 -0
- bugcap/mcp_server.py +221 -0
- bugcap/paths.py +116 -0
- bugcap/recorder.py +390 -0
- bugcap/refs.py +213 -0
- bugcap/repo.py +138 -0
- bugcap/service.py +418 -0
- bugcap/store.py +485 -0
- bugcap/sync.py +424 -0
- bugcap/tomlio.py +37 -0
- bugcap/validation.py +74 -0
- bugcap-0.2.2.dist-info/METADATA +225 -0
- bugcap-0.2.2.dist-info/RECORD +36 -0
- bugcap-0.2.2.dist-info/WHEEL +5 -0
- bugcap-0.2.2.dist-info/entry_points.txt +2 -0
- bugcap-0.2.2.dist-info/licenses/LICENSE +21 -0
- bugcap-0.2.2.dist-info/top_level.txt +1 -0
bugcap/recorder.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Screen recording: ffmpeg (all OSes) or wf-recorder (Wayland), producing a video, an animated
|
|
2
|
+
GIF, or a few keyframes. Argument construction is pure (testable per OS without running
|
|
3
|
+
anything); `run_recording` owns the process, the stop/cap watchdogs and safe finalization."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from .backends import Backend
|
|
20
|
+
from .paths import media_dir
|
|
21
|
+
|
|
22
|
+
FORMATS = ("video", "frames", "animated")
|
|
23
|
+
|
|
24
|
+
RECORD_BACKENDS = [
|
|
25
|
+
Backend(
|
|
26
|
+
name="ffmpeg",
|
|
27
|
+
binaries=["ffmpeg"],
|
|
28
|
+
description="Cross-platform screen recording (X11, Windows, macOS)",
|
|
29
|
+
platforms=("linux", "macos", "windows"),
|
|
30
|
+
annotates=False,
|
|
31
|
+
installs={
|
|
32
|
+
"linux": [
|
|
33
|
+
("apt-get", ["apt-get", "install", "-y", "ffmpeg"]),
|
|
34
|
+
("dnf", ["dnf", "install", "-y", "ffmpeg"]),
|
|
35
|
+
("pacman", ["pacman", "-S", "--noconfirm", "ffmpeg"]),
|
|
36
|
+
("zypper", ["zypper", "install", "-y", "ffmpeg"]),
|
|
37
|
+
("brew", ["brew", "install", "ffmpeg"]),
|
|
38
|
+
],
|
|
39
|
+
"macos": [("brew", ["brew", "install", "ffmpeg"])],
|
|
40
|
+
"windows": [
|
|
41
|
+
("winget", ["winget", "install", "--id", "Gyan.FFmpeg", "-e"]),
|
|
42
|
+
("scoop", ["scoop", "install", "ffmpeg"]),
|
|
43
|
+
("choco", ["choco", "install", "-y", "ffmpeg"]),
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
manual={
|
|
47
|
+
"linux": "Install ffmpeg from your distro (e.g. sudo apt install ffmpeg).",
|
|
48
|
+
"macos": "brew install ffmpeg",
|
|
49
|
+
"windows": "winget install Gyan.FFmpeg, or https://ffmpeg.org/download.html",
|
|
50
|
+
},
|
|
51
|
+
),
|
|
52
|
+
Backend(
|
|
53
|
+
name="wf-recorder",
|
|
54
|
+
binaries=["wf-recorder"],
|
|
55
|
+
description="Wayland screen recording (ffmpeg's x11grab cannot capture Wayland)",
|
|
56
|
+
platforms=("linux",),
|
|
57
|
+
annotates=False,
|
|
58
|
+
installs={
|
|
59
|
+
"linux": [
|
|
60
|
+
("apt-get", ["apt-get", "install", "-y", "wf-recorder"]),
|
|
61
|
+
("dnf", ["dnf", "install", "-y", "wf-recorder"]),
|
|
62
|
+
("pacman", ["pacman", "-S", "--noconfirm", "wf-recorder"]),
|
|
63
|
+
("zypper", ["zypper", "install", "-y", "wf-recorder"]),
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
manual={"linux": "Install wf-recorder from your distro, or https://github.com/ammen99/wf-recorder"},
|
|
67
|
+
),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
MACOS_PERMISSION_HELP = (
|
|
71
|
+
"macOS blocked screen recording. Open System Settings > Privacy & Security > Screen Recording, "
|
|
72
|
+
"allow your terminal app, then run the command again."
|
|
73
|
+
)
|
|
74
|
+
WAYLAND_HELP = (
|
|
75
|
+
"Recording a Wayland session needs wf-recorder (ffmpeg's x11grab cannot capture Wayland). "
|
|
76
|
+
"Install it, e.g. sudo apt install wf-recorder."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RecorderError(RuntimeError):
|
|
81
|
+
def __init__(self, message: str, guidance: Optional[str] = None):
|
|
82
|
+
super().__init__(message)
|
|
83
|
+
self.guidance = guidance
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def platform_key(platform: Optional[str] = None) -> str:
|
|
87
|
+
platform = platform or sys.platform
|
|
88
|
+
if platform in ("windows", "win32"):
|
|
89
|
+
return "windows"
|
|
90
|
+
if platform in ("macos", "darwin"):
|
|
91
|
+
return "macos"
|
|
92
|
+
return "linux"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def by_name(name: str) -> Backend:
|
|
96
|
+
for backend in RECORD_BACKENDS:
|
|
97
|
+
if backend.name == name:
|
|
98
|
+
return backend
|
|
99
|
+
raise KeyError(name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def detect_recorder(platform: Optional[str] = None, env: Optional[dict] = None, which=shutil.which) -> Optional[Backend]:
|
|
103
|
+
"""wf-recorder on Wayland when installed, otherwise ffmpeg, otherwise None."""
|
|
104
|
+
platform = platform_key(platform)
|
|
105
|
+
env = os.environ if env is None else env
|
|
106
|
+
if platform == "linux" and env.get("WAYLAND_DISPLAY") and which("wf-recorder"):
|
|
107
|
+
return by_name("wf-recorder")
|
|
108
|
+
if which("ffmpeg"):
|
|
109
|
+
return by_name("ffmpeg")
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- argument construction (pure) ----------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _scale_filter(width: int = 960) -> str:
|
|
116
|
+
return f"scale='min({width},iw)':-2:flags=lanczos"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_capture_argv(
|
|
120
|
+
backend: str,
|
|
121
|
+
platform: str,
|
|
122
|
+
fmt: str,
|
|
123
|
+
fps: int,
|
|
124
|
+
max_seconds: int,
|
|
125
|
+
out_path: str,
|
|
126
|
+
display: Optional[str] = None,
|
|
127
|
+
codec: str = "h264",
|
|
128
|
+
av_index: int = 0,
|
|
129
|
+
) -> list[str]:
|
|
130
|
+
"""The command that records the screen to `out_path` (always a real video; animated and
|
|
131
|
+
frames are derived from it afterwards)."""
|
|
132
|
+
platform = platform_key(platform)
|
|
133
|
+
if fmt not in FORMATS:
|
|
134
|
+
raise ValueError(f"unknown format {fmt!r}")
|
|
135
|
+
vp9 = codec == "vp9" and fmt == "video"
|
|
136
|
+
if backend == "wf-recorder":
|
|
137
|
+
argv = ["wf-recorder", "-r", str(fps)]
|
|
138
|
+
argv += ["-c", "libvpx-vp9"] if vp9 else ["-c", "libx264", "-x", "yuv420p"]
|
|
139
|
+
return argv + ["-f", out_path]
|
|
140
|
+
|
|
141
|
+
argv = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "warning"]
|
|
142
|
+
if platform == "linux":
|
|
143
|
+
argv += ["-f", "x11grab", "-framerate", str(fps), "-i", display or ":0.0"]
|
|
144
|
+
elif platform == "windows":
|
|
145
|
+
argv += ["-f", "gdigrab", "-framerate", str(fps), "-i", "desktop"]
|
|
146
|
+
else:
|
|
147
|
+
argv += ["-f", "avfoundation", "-framerate", str(fps), "-capture_cursor", "1", "-i", f"{av_index}:none"]
|
|
148
|
+
argv += ["-t", str(max_seconds)]
|
|
149
|
+
argv += ["-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2"]
|
|
150
|
+
if vp9:
|
|
151
|
+
argv += ["-c:v", "libvpx-vp9", "-crf", "35", "-b:v", "0"]
|
|
152
|
+
else:
|
|
153
|
+
argv += ["-c:v", "libx264", "-crf", "30", "-pix_fmt", "yuv420p", "-preset", "veryfast"]
|
|
154
|
+
return argv + [out_path]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_postprocess_argv(fmt: str, in_path: str, out_path: str, fps: float, max_frames: int = 8) -> list[str]:
|
|
158
|
+
"""Derive an animated GIF (palette two-pass in one filter graph) or keyframes (`out_path`
|
|
159
|
+
is a printf pattern such as `frame-%03d.png`) from a recorded video."""
|
|
160
|
+
if fmt == "animated":
|
|
161
|
+
graph = f"fps={fps:g},{_scale_filter()},split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse"
|
|
162
|
+
return ["ffmpeg", "-y", "-hide_banner", "-loglevel", "warning", "-i", in_path,
|
|
163
|
+
"-vf", graph, "-loop", "0", out_path]
|
|
164
|
+
if fmt == "frames":
|
|
165
|
+
return ["ffmpeg", "-y", "-hide_banner", "-loglevel", "warning", "-i", in_path,
|
|
166
|
+
"-vf", f"fps={fps:.4f},{_scale_filter(1280)}", "-frames:v", str(max_frames), out_path]
|
|
167
|
+
raise ValueError(f"no post-processing for format {fmt!r}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_AV_SCREEN = re.compile(r"\[(\d+)\]\s+Capture screen \d+")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_avfoundation_screen_index(output: str) -> int:
|
|
174
|
+
match = _AV_SCREEN.search(output)
|
|
175
|
+
if not match:
|
|
176
|
+
raise RecorderError("no screen capture device found by ffmpeg/avfoundation", MACOS_PERMISSION_HELP)
|
|
177
|
+
return int(match.group(1))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def diagnose_failure(stderr: str, platform: str) -> Optional[str]:
|
|
181
|
+
text = stderr or ""
|
|
182
|
+
platform = platform_key(platform)
|
|
183
|
+
if platform == "macos" and re.search(r"Input/output error|not permitted|permission|denied", text, re.I):
|
|
184
|
+
return MACOS_PERMISSION_HELP
|
|
185
|
+
if platform == "linux" and re.search(r"Cannot open display|x11grab|Could not open", text, re.I):
|
|
186
|
+
return WAYLAND_HELP
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --- running -------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class RecordResult:
|
|
194
|
+
path: Optional[Path]
|
|
195
|
+
size_bytes: int
|
|
196
|
+
reason: str
|
|
197
|
+
duration: float
|
|
198
|
+
kind: str
|
|
199
|
+
mime: str
|
|
200
|
+
frames: list = field(default_factory=list) # [(path, size)]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _size(path) -> int:
|
|
204
|
+
try:
|
|
205
|
+
return os.path.getsize(path)
|
|
206
|
+
except OSError:
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _stop_process(proc, backend: str, platform: str) -> None:
|
|
211
|
+
if proc.poll() is not None:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
if backend == "ffmpeg":
|
|
215
|
+
try:
|
|
216
|
+
proc.stdin.write(b"q")
|
|
217
|
+
proc.stdin.flush()
|
|
218
|
+
proc.stdin.close()
|
|
219
|
+
except (OSError, ValueError, AttributeError):
|
|
220
|
+
proc.terminate()
|
|
221
|
+
elif platform_key(platform) == "windows": # pragma: no cover
|
|
222
|
+
proc.terminate()
|
|
223
|
+
else:
|
|
224
|
+
proc.send_signal(signal.SIGINT)
|
|
225
|
+
proc.wait(timeout=15)
|
|
226
|
+
except subprocess.TimeoutExpired:
|
|
227
|
+
proc.terminate()
|
|
228
|
+
try:
|
|
229
|
+
proc.wait(timeout=5)
|
|
230
|
+
except subprocess.TimeoutExpired:
|
|
231
|
+
proc.kill()
|
|
232
|
+
proc.wait()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def run_recording(
|
|
236
|
+
backend: str,
|
|
237
|
+
fmt: str,
|
|
238
|
+
fps: int,
|
|
239
|
+
max_seconds: int,
|
|
240
|
+
max_mb: float,
|
|
241
|
+
out_dir: Optional[Path] = None,
|
|
242
|
+
wait_for_stop: Callable[[], bool] = lambda: False,
|
|
243
|
+
*,
|
|
244
|
+
platform: Optional[str] = None,
|
|
245
|
+
env: Optional[dict] = None,
|
|
246
|
+
codec: str = "h264",
|
|
247
|
+
popen=subprocess.Popen,
|
|
248
|
+
runner=subprocess.run,
|
|
249
|
+
clock: Callable[[], float] = time.monotonic,
|
|
250
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
251
|
+
size_of: Callable[[object], int] = _size,
|
|
252
|
+
poll_interval: float = 0.2,
|
|
253
|
+
) -> RecordResult:
|
|
254
|
+
"""Record until `wait_for_stop()` is true, Ctrl+C, or a cap is hit; always wait for the
|
|
255
|
+
recorder to exit so the file is complete; then derive animated/frames if requested."""
|
|
256
|
+
platform = platform_key(platform)
|
|
257
|
+
env = os.environ if env is None else env
|
|
258
|
+
try:
|
|
259
|
+
out_dir = Path(out_dir) if out_dir else media_dir()
|
|
260
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
except OSError as exc:
|
|
262
|
+
raise RecorderError(f"cannot use the output folder: {exc.strerror or exc}") from exc
|
|
263
|
+
if not os.access(out_dir, os.W_OK):
|
|
264
|
+
raise RecorderError(f"the output folder is not writable: {out_dir}")
|
|
265
|
+
stem = uuid.uuid4().hex
|
|
266
|
+
ext = ".webm" if (codec == "vp9" and fmt == "video") else ".mp4"
|
|
267
|
+
raw = out_dir / f"{stem}{ext}"
|
|
268
|
+
|
|
269
|
+
av_index = 0
|
|
270
|
+
if backend == "ffmpeg" and platform == "macos":
|
|
271
|
+
probe = runner(["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
|
272
|
+
capture_output=True, text=True)
|
|
273
|
+
av_index = parse_avfoundation_screen_index((probe.stderr or "") + (probe.stdout or ""))
|
|
274
|
+
|
|
275
|
+
argv = build_capture_argv(backend, platform, fmt, fps, max_seconds, str(raw),
|
|
276
|
+
display=env.get("DISPLAY"), codec=codec, av_index=av_index)
|
|
277
|
+
log = tempfile.TemporaryFile()
|
|
278
|
+
try:
|
|
279
|
+
proc = popen(argv, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=log)
|
|
280
|
+
except OSError as exc:
|
|
281
|
+
log.close()
|
|
282
|
+
raise RecorderError(f"could not start {backend}: {exc}", guidance_for(backend, platform)) from exc
|
|
283
|
+
|
|
284
|
+
started = clock()
|
|
285
|
+
reason = "stopped by user"
|
|
286
|
+
max_bytes = int(max_mb * 1024 * 1024)
|
|
287
|
+
try:
|
|
288
|
+
while True:
|
|
289
|
+
if proc.poll() is not None:
|
|
290
|
+
reason = "recorder exited"
|
|
291
|
+
break
|
|
292
|
+
if wait_for_stop():
|
|
293
|
+
reason = "stopped by user"
|
|
294
|
+
break
|
|
295
|
+
if clock() - started >= max_seconds:
|
|
296
|
+
reason = "duration cap"
|
|
297
|
+
break
|
|
298
|
+
if size_of(raw) >= max_bytes:
|
|
299
|
+
reason = "size cap"
|
|
300
|
+
break
|
|
301
|
+
sleep(poll_interval)
|
|
302
|
+
except KeyboardInterrupt:
|
|
303
|
+
reason = "interrupted"
|
|
304
|
+
def _finish() -> RecordResult:
|
|
305
|
+
duration = clock() - started
|
|
306
|
+
_stop_process(proc, backend, platform)
|
|
307
|
+
|
|
308
|
+
log.seek(0)
|
|
309
|
+
stderr_text = log.read().decode("utf-8", "replace")
|
|
310
|
+
log.close()
|
|
311
|
+
if size_of(raw) == 0:
|
|
312
|
+
raw_exists = Path(raw).exists()
|
|
313
|
+
if raw_exists:
|
|
314
|
+
Path(raw).unlink(missing_ok=True)
|
|
315
|
+
raise RecorderError(
|
|
316
|
+
"the recording is empty" + (f": {stderr_text.strip().splitlines()[-1]}" if stderr_text.strip() else ""),
|
|
317
|
+
diagnose_failure(stderr_text, platform),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if fmt == "video":
|
|
321
|
+
mime = "video/webm" if raw.suffix == ".webm" else "video/mp4"
|
|
322
|
+
return RecordResult(raw, size_of(raw), reason, duration, "video", mime)
|
|
323
|
+
|
|
324
|
+
if fmt == "animated":
|
|
325
|
+
out = out_dir / f"{stem}.gif"
|
|
326
|
+
try:
|
|
327
|
+
_run_checked(runner, build_postprocess_argv("animated", str(raw), str(out), min(fps, 8)))
|
|
328
|
+
except RecorderError:
|
|
329
|
+
raw.unlink(missing_ok=True)
|
|
330
|
+
out.unlink(missing_ok=True)
|
|
331
|
+
raise
|
|
332
|
+
raw.unlink(missing_ok=True)
|
|
333
|
+
return RecordResult(out, size_of(out), reason, duration, "animated", "image/gif")
|
|
334
|
+
|
|
335
|
+
frames_dir = out_dir / f"{stem}-frames"
|
|
336
|
+
frames_dir.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
pattern = str(frames_dir / "frame-%03d.png")
|
|
338
|
+
per_second = 8 / max(duration, 1.0)
|
|
339
|
+
try:
|
|
340
|
+
_run_checked(runner, build_postprocess_argv("frames", str(raw), pattern, min(float(fps), per_second), 8))
|
|
341
|
+
frames = [(str(p), size_of(p)) for p in sorted(frames_dir.glob("frame-*.png"))]
|
|
342
|
+
if not frames:
|
|
343
|
+
raise RecorderError("no frames could be extracted from the recording")
|
|
344
|
+
except RecorderError:
|
|
345
|
+
shutil.rmtree(frames_dir, ignore_errors=True)
|
|
346
|
+
raise
|
|
347
|
+
finally:
|
|
348
|
+
raw.unlink(missing_ok=True)
|
|
349
|
+
return RecordResult(None, sum(s for _, s in frames), reason, duration, "frames", "image/png", frames)
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
return _finish()
|
|
353
|
+
except KeyboardInterrupt:
|
|
354
|
+
# Ctrl+C while finalizing: never attach (or leave behind) a partial file.
|
|
355
|
+
if proc.poll() is None:
|
|
356
|
+
proc.kill()
|
|
357
|
+
for leftover in (raw, out_dir / f"{stem}.gif"):
|
|
358
|
+
Path(leftover).unlink(missing_ok=True)
|
|
359
|
+
shutil.rmtree(out_dir / f"{stem}-frames", ignore_errors=True)
|
|
360
|
+
raise RecorderError("interrupted while finishing the recording; nothing was attached") from None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def discard(result: "RecordResult") -> None:
|
|
364
|
+
"""Delete the files of a recording that will not be attached."""
|
|
365
|
+
if result.path:
|
|
366
|
+
Path(result.path).unlink(missing_ok=True)
|
|
367
|
+
for frame_path, _ in result.frames:
|
|
368
|
+
Path(frame_path).unlink(missing_ok=True)
|
|
369
|
+
if result.frames:
|
|
370
|
+
try:
|
|
371
|
+
Path(result.frames[0][0]).parent.rmdir()
|
|
372
|
+
except OSError:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _run_checked(runner, argv: list[str]) -> None:
|
|
377
|
+
try:
|
|
378
|
+
result = runner(argv, capture_output=True, text=True)
|
|
379
|
+
except OSError as exc:
|
|
380
|
+
raise RecorderError(
|
|
381
|
+
"ffmpeg is needed to make animated/frames output", guidance_for("ffmpeg")
|
|
382
|
+
) from exc
|
|
383
|
+
if getattr(result, "returncode", 0) != 0:
|
|
384
|
+
raise RecorderError(f"ffmpeg post-processing failed: {(getattr(result, 'stderr', '') or '').strip()[-300:]}")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def guidance_for(backend: str, platform: Optional[str] = None) -> str:
|
|
388
|
+
b = by_name(backend)
|
|
389
|
+
key = platform_key(platform)
|
|
390
|
+
return b.manual.get(key, "See the project's website for install steps.")
|
bugcap/refs.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""`@` references in notes: `@3` (media index), `@login-error` (media label), `@@` (literal @).
|
|
2
|
+
|
|
3
|
+
References are ignored inside fenced code blocks, inline code spans and email-like text.
|
|
4
|
+
Everything here is pure text processing; the rules for what a reference may point at
|
|
5
|
+
live in `validate_references`."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal, Optional
|
|
11
|
+
|
|
12
|
+
from .errors import ServiceError
|
|
13
|
+
|
|
14
|
+
REMOVED_TEXT = "[image removed]"
|
|
15
|
+
LABEL_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_\-]*$")
|
|
16
|
+
_REF_RE = re.compile(r"@@|(?<![\w.%+\-])@([A-Za-z0-9_\-]+)(?![\w\-])")
|
|
17
|
+
_FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Ref:
|
|
22
|
+
token: str # text as written, e.g. "@1", "@login-error", "@@"
|
|
23
|
+
start: int
|
|
24
|
+
end: int
|
|
25
|
+
kind: Literal["index", "label", "escape", "unknown"]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return self.token[1:]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _blank(text: str) -> str:
|
|
33
|
+
return "".join(ch if ch == "\n" else " " for ch in text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def mask(notes: str) -> str:
|
|
37
|
+
"""Replace fenced blocks and inline code spans with spaces (same length, same newlines)."""
|
|
38
|
+
out: list[str] = []
|
|
39
|
+
fence: Optional[tuple[str, int]] = None # (char, length) of the open fence
|
|
40
|
+
prose: list[str] = [] # consecutive non-fence lines, so code spans may cross lines
|
|
41
|
+
|
|
42
|
+
def flush():
|
|
43
|
+
if prose:
|
|
44
|
+
out.append(_mask_spans("".join(prose)))
|
|
45
|
+
prose.clear()
|
|
46
|
+
|
|
47
|
+
for line in notes.splitlines(keepends=True):
|
|
48
|
+
stripped = line.rstrip("\r\n")
|
|
49
|
+
if fence is None:
|
|
50
|
+
match = _FENCE_RE.match(stripped)
|
|
51
|
+
if match:
|
|
52
|
+
flush()
|
|
53
|
+
run = match.group(1)
|
|
54
|
+
fence = (run[0], len(run))
|
|
55
|
+
out.append(_blank(line))
|
|
56
|
+
else:
|
|
57
|
+
prose.append(line)
|
|
58
|
+
else:
|
|
59
|
+
out.append(_blank(line))
|
|
60
|
+
match = _FENCE_RE.match(stripped)
|
|
61
|
+
if (
|
|
62
|
+
match
|
|
63
|
+
and match.group(1)[0] == fence[0]
|
|
64
|
+
and len(match.group(1)) >= fence[1]
|
|
65
|
+
and not stripped.strip().strip(fence[0])
|
|
66
|
+
):
|
|
67
|
+
fence = None
|
|
68
|
+
flush()
|
|
69
|
+
return "".join(out)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _mask_spans(text: str) -> str:
|
|
73
|
+
chars = list(text)
|
|
74
|
+
i, n = 0, len(text)
|
|
75
|
+
while i < n:
|
|
76
|
+
if text[i] != "`":
|
|
77
|
+
i += 1
|
|
78
|
+
continue
|
|
79
|
+
j = i
|
|
80
|
+
while j < n and text[j] == "`":
|
|
81
|
+
j += 1
|
|
82
|
+
run = j - i
|
|
83
|
+
k = j
|
|
84
|
+
closed = -1
|
|
85
|
+
while k < n:
|
|
86
|
+
if text[k] == "`":
|
|
87
|
+
m = k
|
|
88
|
+
while m < n and text[m] == "`":
|
|
89
|
+
m += 1
|
|
90
|
+
if m - k == run:
|
|
91
|
+
closed = m
|
|
92
|
+
break
|
|
93
|
+
k = m
|
|
94
|
+
else:
|
|
95
|
+
k += 1
|
|
96
|
+
if closed == -1:
|
|
97
|
+
i = j # unmatched backticks are literal
|
|
98
|
+
else:
|
|
99
|
+
for p in range(i, closed):
|
|
100
|
+
if chars[p] != "\n":
|
|
101
|
+
chars[p] = " "
|
|
102
|
+
i = closed
|
|
103
|
+
return "".join(chars)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _classify(name: str) -> str:
|
|
107
|
+
if name.isdigit() and not name.startswith("0"):
|
|
108
|
+
return "index"
|
|
109
|
+
if LABEL_RE.match(name):
|
|
110
|
+
return "label"
|
|
111
|
+
return "unknown"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_references(notes: str) -> list[Ref]:
|
|
115
|
+
masked = mask(notes or "")
|
|
116
|
+
refs: list[Ref] = []
|
|
117
|
+
for match in _REF_RE.finditer(masked):
|
|
118
|
+
if match.group(0) == "@@":
|
|
119
|
+
refs.append(Ref("@@", match.start(), match.end(), "escape"))
|
|
120
|
+
else:
|
|
121
|
+
refs.append(Ref(match.group(0), match.start(), match.end(), _classify(match.group(1)))) # type: ignore[arg-type]
|
|
122
|
+
return refs
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve(ref: Ref, media: list):
|
|
126
|
+
"""The media item a reference points at (label match is case-sensitive), or None."""
|
|
127
|
+
if ref.kind == "index":
|
|
128
|
+
for item in media:
|
|
129
|
+
if item.idx == int(ref.name):
|
|
130
|
+
return item
|
|
131
|
+
elif ref.kind == "label":
|
|
132
|
+
for item in media:
|
|
133
|
+
if item.label == ref.name:
|
|
134
|
+
return item
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def valid_tokens(media: list) -> list[str]:
|
|
139
|
+
indexes = [f"@{m.idx}" for m in sorted(media, key=lambda m: m.idx)]
|
|
140
|
+
labels = [f"@{m.label}" for m in sorted(media, key=lambda m: m.idx) if m.label]
|
|
141
|
+
return indexes + labels
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_references(notes: str, media: list) -> None:
|
|
145
|
+
for ref in parse_references(notes):
|
|
146
|
+
if ref.kind == "escape":
|
|
147
|
+
continue
|
|
148
|
+
if resolve(ref, media) is None:
|
|
149
|
+
valid = valid_tokens(media)
|
|
150
|
+
raise ServiceError(
|
|
151
|
+
"invalid_reference",
|
|
152
|
+
f"unknown reference {ref.token} in notes",
|
|
153
|
+
{"token": ref.token, "valid": valid},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _rewrite(notes: str, mapping: dict[str, str]) -> tuple[str, int]:
|
|
158
|
+
out = notes
|
|
159
|
+
count = 0
|
|
160
|
+
for ref in reversed(parse_references(notes)):
|
|
161
|
+
if ref.kind in ("index", "label") and ref.name in mapping:
|
|
162
|
+
replacement = mapping[ref.name]
|
|
163
|
+
if replacement != ref.token:
|
|
164
|
+
out = out[: ref.start] + replacement + out[ref.end :]
|
|
165
|
+
count += 1
|
|
166
|
+
return out, count
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def rewrite_references(notes: str, mapping: dict[str, str]) -> str:
|
|
170
|
+
"""Replace references by name (`{"2": "@1", "old": "@new", "3": "[image removed]"}`).
|
|
171
|
+
Code, emails and `@@` are never touched; other text is preserved exactly."""
|
|
172
|
+
return _rewrite(notes, mapping)[0]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def rewrite_references_counted(notes: str, mapping: dict[str, str]) -> tuple[str, int]:
|
|
176
|
+
return _rewrite(notes, mapping)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def references_to(notes: str, item, media_all: list) -> list[Ref]:
|
|
180
|
+
"""References in `notes` that resolve to `item`."""
|
|
181
|
+
return [
|
|
182
|
+
r
|
|
183
|
+
for r in parse_references(notes)
|
|
184
|
+
if (found := resolve(r, media_all)) is not None and found.id == item.id
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def display_notes(notes: str, media: list) -> str:
|
|
189
|
+
"""Notes for terminal display: `@1` becomes `@1 (images/…)` and `@@` prints as `@`."""
|
|
190
|
+
out = notes or ""
|
|
191
|
+
for ref in reversed(parse_references(out)):
|
|
192
|
+
if ref.kind == "escape":
|
|
193
|
+
out = out[: ref.start] + "@" + out[ref.end :]
|
|
194
|
+
elif ref.kind in ("index", "label"):
|
|
195
|
+
item = resolve(ref, media)
|
|
196
|
+
if item is not None:
|
|
197
|
+
where = item.path or f"{len(item.frames)} frames"
|
|
198
|
+
out = out[: ref.start] + f"{ref.token} ({where})" + out[ref.end :]
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def substitute(notes: str, media: list, replacements: dict) -> str:
|
|
203
|
+
"""Notes for an outside destination: each resolvable reference becomes
|
|
204
|
+
`replacements[media.id]` (left as written when absent) and `@@` becomes `@`."""
|
|
205
|
+
out = notes or ""
|
|
206
|
+
for ref in reversed(parse_references(out)):
|
|
207
|
+
if ref.kind == "escape":
|
|
208
|
+
out = out[: ref.start] + "@" + out[ref.end :]
|
|
209
|
+
elif ref.kind in ("index", "label"):
|
|
210
|
+
item = resolve(ref, media)
|
|
211
|
+
if item is not None and item.id in replacements:
|
|
212
|
+
out = out[: ref.start] + replacements[item.id] + out[ref.end :]
|
|
213
|
+
return out
|