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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
bugcap/agent_api.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""SDK-free implementations of the agent tools. `mcp_server.py` merely registers
|
|
2
|
+
these, so the logic is unit-testable without the MCP SDK installed."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
from . import capture, refs, repo, service, sync
|
|
11
|
+
from .errors import ServiceError
|
|
12
|
+
from .store import Report, Store
|
|
13
|
+
|
|
14
|
+
_MIME = {
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
".bmp": "image/bmp",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mime(path) -> str:
|
|
25
|
+
return _MIME.get(Path(path).suffix.lower(), "application/octet-stream")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _summary(report: Report) -> dict:
|
|
29
|
+
return {
|
|
30
|
+
"id": report.id,
|
|
31
|
+
"title": report.title,
|
|
32
|
+
"status": report.status,
|
|
33
|
+
"tags": report.tags,
|
|
34
|
+
"repo": report.repo,
|
|
35
|
+
"created_at": report.created_at,
|
|
36
|
+
"synced_refs": report.synced_refs,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _current_repo_key() -> Optional[str]:
|
|
41
|
+
cfg = repo.load_repo_config()
|
|
42
|
+
return cfg.key if cfg else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_reports(
|
|
46
|
+
store: Store,
|
|
47
|
+
all: bool = False,
|
|
48
|
+
status: Optional[str] = None,
|
|
49
|
+
limit: Optional[int] = None,
|
|
50
|
+
) -> list[dict]:
|
|
51
|
+
scope = None if all else _current_repo_key()
|
|
52
|
+
reports = store.list(repo=scope)
|
|
53
|
+
if status is not None:
|
|
54
|
+
reports = [r for r in reports if r.status == status]
|
|
55
|
+
if limit is not None:
|
|
56
|
+
reports = reports[: max(0, int(limit))]
|
|
57
|
+
return [_summary(r) for r in reports]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _media_meta(media) -> list[dict]:
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
"index": m.idx,
|
|
64
|
+
"label": m.label,
|
|
65
|
+
"kind": m.kind,
|
|
66
|
+
"mime": m.mime,
|
|
67
|
+
"size_bytes": m.size_bytes,
|
|
68
|
+
**({"frames": len(m.frames)} if m.kind == "frames" else {}),
|
|
69
|
+
}
|
|
70
|
+
for m in media
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_report(store: Store, id: int) -> dict:
|
|
75
|
+
report = store.get(id)
|
|
76
|
+
if report is None:
|
|
77
|
+
return {"error": f"no report with id {id}"}
|
|
78
|
+
data = _summary(report)
|
|
79
|
+
data.update({
|
|
80
|
+
"body": report.body,
|
|
81
|
+
"notes": report.notes,
|
|
82
|
+
"image_paths": report.image_paths,
|
|
83
|
+
"media": _media_meta(report.media),
|
|
84
|
+
})
|
|
85
|
+
images = []
|
|
86
|
+
for path in report.image_paths:
|
|
87
|
+
p = Path(path)
|
|
88
|
+
if p.is_file():
|
|
89
|
+
images.append({"path": path, "mime": _mime(p), "bytes": p.read_bytes()})
|
|
90
|
+
resolved = [
|
|
91
|
+
{"token": r.token, "index": found.idx}
|
|
92
|
+
for r in refs.parse_references(report.notes)
|
|
93
|
+
if r.kind in ("index", "label") and (found := refs.resolve(r, report.media)) is not None
|
|
94
|
+
]
|
|
95
|
+
return {"report": data, "images": images, "resolved_references": resolved}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def attach_image(store: Store, id: int, sources: list[str], labels: Optional[list] = None) -> dict:
|
|
99
|
+
"""Attach images (paths, globs, http(s) URLs) to a report; partial batches are reported."""
|
|
100
|
+
if not sources or len(sources) > 20:
|
|
101
|
+
return {"code": "bad_query", "error": "sources must contain 1 to 20 entries"}
|
|
102
|
+
try:
|
|
103
|
+
result = service.add_media(store, id, list(sources), labels)
|
|
104
|
+
except ServiceError as exc:
|
|
105
|
+
return {"error": exc.message, **exc.as_dict()}
|
|
106
|
+
return {
|
|
107
|
+
"report_id": id,
|
|
108
|
+
"added": [
|
|
109
|
+
{"index": m.idx, "label": m.label, "kind": m.kind, "size_bytes": m.size_bytes, "source": m.source}
|
|
110
|
+
for m in result.added
|
|
111
|
+
],
|
|
112
|
+
"rejected": [{"source": r["source"], "reason": r["reason"]} for r in result.rejected],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def update_notes(store: Store, id: int, notes: str) -> dict:
|
|
117
|
+
"""Replace a report's notes, validating `@` references against its images."""
|
|
118
|
+
try:
|
|
119
|
+
report = service.set_notes(store, id, notes)
|
|
120
|
+
except ServiceError as exc:
|
|
121
|
+
return {"error": exc.message, **exc.as_dict()}
|
|
122
|
+
view = service.get_report_view(store, id)
|
|
123
|
+
return {"report_id": report.id, "notes": report.notes, "references": view.references}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_slug(explicit: Optional[str]) -> Optional[str]:
|
|
127
|
+
if explicit:
|
|
128
|
+
return explicit
|
|
129
|
+
cfg = repo.load_repo_config()
|
|
130
|
+
return cfg.github if cfg else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _resolve_target(store: Store, report_id: Optional[int], issue: Optional[str]) -> Optional[Report]:
|
|
134
|
+
"""Find (or create from an issue) the report a screenshot is being requested for."""
|
|
135
|
+
if report_id is not None:
|
|
136
|
+
return store.get(report_id)
|
|
137
|
+
if not issue:
|
|
138
|
+
return None
|
|
139
|
+
if "#" in issue:
|
|
140
|
+
slug, _, number = issue.partition("#")
|
|
141
|
+
slug = slug or (_resolve_slug(None) or "")
|
|
142
|
+
else:
|
|
143
|
+
slug = _resolve_slug(None) or ""
|
|
144
|
+
number = issue
|
|
145
|
+
ref = f"{slug}#{number}"
|
|
146
|
+
existing = store.find_by_ref("github.issue", ref)
|
|
147
|
+
if existing is not None:
|
|
148
|
+
return existing
|
|
149
|
+
return store.add(
|
|
150
|
+
title=f"Issue {ref}", repo=slug or None, synced_refs={"github.issue": ref}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _capture_with_timeout(timeout_seconds: int) -> Path:
|
|
155
|
+
result: dict = {}
|
|
156
|
+
|
|
157
|
+
def worker():
|
|
158
|
+
try:
|
|
159
|
+
result["path"] = capture.capture_screenshot()
|
|
160
|
+
except BaseException as exc: # re-raised on the calling thread
|
|
161
|
+
result["error"] = exc
|
|
162
|
+
|
|
163
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
164
|
+
thread.start()
|
|
165
|
+
thread.join(timeout_seconds)
|
|
166
|
+
if thread.is_alive():
|
|
167
|
+
raise TimeoutError("screenshot request timed out")
|
|
168
|
+
if "error" in result:
|
|
169
|
+
raise result["error"]
|
|
170
|
+
return result["path"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def request_screenshot(
|
|
174
|
+
store: Store,
|
|
175
|
+
report_id: Optional[int] = None,
|
|
176
|
+
issue: Optional[str] = None,
|
|
177
|
+
message: Optional[str] = None,
|
|
178
|
+
timeout_seconds: int = 300,
|
|
179
|
+
note: Optional[str] = None,
|
|
180
|
+
) -> dict:
|
|
181
|
+
if not capture.has_display():
|
|
182
|
+
return {"status": "non_interactive", "message": "no interactive desktop UI available"}
|
|
183
|
+
report = _resolve_target(store, report_id, issue)
|
|
184
|
+
if report is None:
|
|
185
|
+
return {"status": "error", "message": "no target report or issue given"}
|
|
186
|
+
if message:
|
|
187
|
+
print(message, file=sys.stderr, flush=True)
|
|
188
|
+
print(f"bugcap: capture a screenshot for report #{report.id}...", file=sys.stderr, flush=True)
|
|
189
|
+
try:
|
|
190
|
+
path = _capture_with_timeout(timeout_seconds)
|
|
191
|
+
except capture.CaptureError as exc:
|
|
192
|
+
return {"status": "cancelled", "message": str(exc)}
|
|
193
|
+
except TimeoutError as exc:
|
|
194
|
+
return {"status": "cancelled", "message": str(exc)}
|
|
195
|
+
media = service.attach_captured(store, report.id, str(path))
|
|
196
|
+
note_error = None
|
|
197
|
+
if note:
|
|
198
|
+
try:
|
|
199
|
+
service.append_note(store, report.id, note, [media])
|
|
200
|
+
except ServiceError as exc:
|
|
201
|
+
note_error = exc.message
|
|
202
|
+
p = Path(path)
|
|
203
|
+
return {
|
|
204
|
+
**({"note_error": note_error} if note_error else {}),
|
|
205
|
+
"status": "captured",
|
|
206
|
+
"report_id": report.id,
|
|
207
|
+
"image": {"path": str(path), "mime": _mime(p), "bytes": p.read_bytes()},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def pull_issues(
|
|
212
|
+
store: Store,
|
|
213
|
+
repo: Optional[str] = None,
|
|
214
|
+
labels: Optional[list[str]] = None,
|
|
215
|
+
limit: int = 30,
|
|
216
|
+
ask_cb: Optional[Callable] = None,
|
|
217
|
+
) -> dict:
|
|
218
|
+
slug = _resolve_slug(repo)
|
|
219
|
+
if not slug:
|
|
220
|
+
return {"error": "no GitHub repo given and none configured in .bugcap.toml"}
|
|
221
|
+
return sync.pull_issues(store, slug, labels, limit, ask_cb)
|
bugcap/backends.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Capture backends: detection, install suggestions and (opt-in) installation, per OS."""
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def platform_key() -> str:
|
|
11
|
+
if sys.platform == "win32":
|
|
12
|
+
return "windows"
|
|
13
|
+
if sys.platform == "darwin":
|
|
14
|
+
return "macos"
|
|
15
|
+
return "linux"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Backend:
|
|
20
|
+
name: str
|
|
21
|
+
binaries: list[str] # every one must be on PATH
|
|
22
|
+
description: str
|
|
23
|
+
platforms: tuple[str, ...]
|
|
24
|
+
annotates: bool
|
|
25
|
+
# platform -> [(package manager binary, argv)]
|
|
26
|
+
installs: dict[str, list[tuple[str, list[str]]]] = field(default_factory=dict)
|
|
27
|
+
manual: dict[str, str] = field(default_factory=dict) # platform -> guidance
|
|
28
|
+
|
|
29
|
+
def available(self) -> bool:
|
|
30
|
+
if platform_key() not in self.platforms:
|
|
31
|
+
return False
|
|
32
|
+
return all(shutil.which(b) for b in self.binaries)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
BACKENDS = [
|
|
36
|
+
Backend(
|
|
37
|
+
name="flameshot",
|
|
38
|
+
binaries=["flameshot"],
|
|
39
|
+
description="Cross-platform capture + annotation UI (recommended everywhere)",
|
|
40
|
+
platforms=("linux", "macos", "windows"),
|
|
41
|
+
annotates=True,
|
|
42
|
+
installs={
|
|
43
|
+
"linux": [
|
|
44
|
+
("apt-get", ["apt-get", "install", "-y", "flameshot"]),
|
|
45
|
+
("dnf", ["dnf", "install", "-y", "flameshot"]),
|
|
46
|
+
("pacman", ["pacman", "-S", "--noconfirm", "flameshot"]),
|
|
47
|
+
("zypper", ["zypper", "install", "-y", "flameshot"]),
|
|
48
|
+
("brew", ["brew", "install", "flameshot"]),
|
|
49
|
+
],
|
|
50
|
+
"macos": [("brew", ["brew", "install", "--cask", "flameshot"])],
|
|
51
|
+
"windows": [
|
|
52
|
+
("winget", ["winget", "install", "--id", "Flameshot.Flameshot", "-e"]),
|
|
53
|
+
("scoop", ["scoop", "install", "flameshot"]),
|
|
54
|
+
("choco", ["choco", "install", "-y", "flameshot"]),
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
manual={
|
|
58
|
+
"linux": "See https://flameshot.org/docs/installation/",
|
|
59
|
+
"macos": "brew install --cask flameshot, or https://flameshot.org/#download",
|
|
60
|
+
"windows": "winget install Flameshot.Flameshot, or https://flameshot.org/#download",
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
Backend(
|
|
64
|
+
name="satty",
|
|
65
|
+
binaries=["satty", "grim"],
|
|
66
|
+
description="Wayland-native annotation (needs grim for the grab)",
|
|
67
|
+
platforms=("linux",),
|
|
68
|
+
annotates=True,
|
|
69
|
+
installs={
|
|
70
|
+
"linux": [
|
|
71
|
+
("pacman", ["pacman", "-S", "--noconfirm", "satty", "grim"]),
|
|
72
|
+
("brew", ["brew", "install", "satty"]),
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
manual={
|
|
76
|
+
"linux": "Install grim from your distro, and satty from "
|
|
77
|
+
"https://github.com/Satty-org/Satty (or `cargo install satty`)",
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
Backend(
|
|
81
|
+
name="screencapture",
|
|
82
|
+
binaries=["screencapture"],
|
|
83
|
+
description="macOS built-in region capture (no annotation)",
|
|
84
|
+
platforms=("macos",),
|
|
85
|
+
annotates=False,
|
|
86
|
+
manual={"macos": "Built in to macOS; nothing to install."},
|
|
87
|
+
),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def by_name(name: str) -> Backend:
|
|
92
|
+
for backend in BACKENDS:
|
|
93
|
+
if backend.name == name:
|
|
94
|
+
return backend
|
|
95
|
+
raise KeyError(name)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detect() -> Optional[Backend]:
|
|
99
|
+
"""First available backend, in preference order."""
|
|
100
|
+
return next((b for b in BACKENDS if b.available()), None)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def recommended() -> Backend:
|
|
104
|
+
"""What to suggest installing on this OS. Flameshot is cross-platform and annotates,
|
|
105
|
+
so it is the recommendation on every supported OS (satty is the Wayland alternative)."""
|
|
106
|
+
return by_name("flameshot")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def install_command(backend: Backend) -> Optional[list[str]]:
|
|
110
|
+
"""Full argv (with sudo when needed) for the first usable package manager, or None."""
|
|
111
|
+
for manager, argv in backend.installs.get(platform_key(), []):
|
|
112
|
+
if not shutil.which(manager):
|
|
113
|
+
continue
|
|
114
|
+
needs_root = manager in {"apt-get", "dnf", "pacman", "zypper"}
|
|
115
|
+
if needs_root and hasattr(os, "geteuid") and os.geteuid() != 0:
|
|
116
|
+
if not shutil.which("sudo"):
|
|
117
|
+
continue
|
|
118
|
+
return ["sudo", *argv]
|
|
119
|
+
return list(argv)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def guidance(backend: Backend) -> str:
|
|
124
|
+
return backend.manual.get(platform_key(), "See the project's website for install steps.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install(backend: Backend) -> int:
|
|
128
|
+
cmd = install_command(backend)
|
|
129
|
+
if cmd is None:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
f"No supported package manager found for {backend.name}. {guidance(backend)}"
|
|
132
|
+
)
|
|
133
|
+
return subprocess.run(cmd).returncode
|
bugcap/capture.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import backends
|
|
9
|
+
from .paths import images_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CaptureError(RuntimeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def has_display() -> bool:
|
|
17
|
+
"""Whether an interactive graphical session is available for a capture UI.
|
|
18
|
+
|
|
19
|
+
Linux: a running X11/Wayland session (``DISPLAY`` or ``WAYLAND_DISPLAY``).
|
|
20
|
+
macOS/Windows: assumed present (the window server runs for logged-in users).
|
|
21
|
+
"""
|
|
22
|
+
if sys.platform == "win32" or sys.platform == "darwin":
|
|
23
|
+
return True
|
|
24
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _no_backend_error() -> CaptureError:
|
|
28
|
+
rec = backends.recommended()
|
|
29
|
+
return CaptureError(
|
|
30
|
+
"No capture tool found. Run `bugcap setup` to detect/install one "
|
|
31
|
+
f"(recommended: {rec.name} - {backends.guidance(rec)}), "
|
|
32
|
+
"or pass an existing screenshot with --image."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def import_image(source: Path) -> Path:
|
|
37
|
+
"""Copy an existing image into the store (no capture tool needed)."""
|
|
38
|
+
if not source.is_file():
|
|
39
|
+
raise CaptureError(f"Image not found: {source}")
|
|
40
|
+
dest = images_dir() / f"{uuid.uuid4()}{source.suffix.lower() or '.png'}"
|
|
41
|
+
shutil.copyfile(source, dest)
|
|
42
|
+
return dest
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def capture_screenshot() -> Path:
|
|
46
|
+
"""Launch the detected backend's interactive capture UI and return the saved PNG path.
|
|
47
|
+
Blocks until the user finishes (saves or cancels).
|
|
48
|
+
"""
|
|
49
|
+
backend = backends.detect()
|
|
50
|
+
if backend is None:
|
|
51
|
+
raise _no_backend_error()
|
|
52
|
+
dest = images_dir() / f"{uuid.uuid4()}.png"
|
|
53
|
+
|
|
54
|
+
if backend.name == "flameshot":
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["flameshot", "gui", "--path", str(dest)], capture_output=True, text=True
|
|
57
|
+
)
|
|
58
|
+
if result.returncode != 0:
|
|
59
|
+
raise CaptureError(f"flameshot exited with {result.returncode}: {result.stderr}")
|
|
60
|
+
elif backend.name == "satty":
|
|
61
|
+
raw = images_dir() / f"{uuid.uuid4()}-raw.png"
|
|
62
|
+
subprocess.run(["grim", str(raw)], check=True)
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["satty", "--filename", str(raw), "--output-filename", str(dest)],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
)
|
|
68
|
+
raw.unlink(missing_ok=True)
|
|
69
|
+
if result.returncode != 0:
|
|
70
|
+
raise CaptureError(f"satty exited with {result.returncode}: {result.stderr}")
|
|
71
|
+
elif backend.name == "screencapture":
|
|
72
|
+
# -i interactive region/window selection (space toggles window mode)
|
|
73
|
+
result = subprocess.run(["screencapture", "-i", str(dest)], capture_output=True, text=True)
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
raise CaptureError(f"screencapture exited with {result.returncode}: {result.stderr}")
|
|
76
|
+
else: # pragma: no cover
|
|
77
|
+
raise CaptureError(f"Unknown backend: {backend.name}")
|
|
78
|
+
|
|
79
|
+
if not dest.exists():
|
|
80
|
+
raise CaptureError(
|
|
81
|
+
"Capture backend exited without saving a file "
|
|
82
|
+
"(did you cancel the capture instead of saving it?)."
|
|
83
|
+
)
|
|
84
|
+
return dest
|