bugcap 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (84) hide show
  1. {bugcap-0.4.0/src/bugcap.egg-info → bugcap-0.5.0}/PKG-INFO +1 -1
  2. {bugcap-0.4.0 → bugcap-0.5.0}/pyproject.toml +1 -1
  3. bugcap-0.5.0/src/bugcap/__init__.py +1 -0
  4. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/cli.py +13 -6
  5. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/ingest.py +36 -4
  6. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/service.py +11 -3
  7. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/store.py +8 -0
  8. {bugcap-0.4.0 → bugcap-0.5.0/src/bugcap.egg-info}/PKG-INFO +1 -1
  9. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_attach.py +74 -0
  10. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_live_session.py +1 -1
  11. bugcap-0.4.0/src/bugcap/__init__.py +0 -1
  12. {bugcap-0.4.0 → bugcap-0.5.0}/LICENSE +0 -0
  13. {bugcap-0.4.0 → bugcap-0.5.0}/README.md +0 -0
  14. {bugcap-0.4.0 → bugcap-0.5.0}/setup.cfg +0 -0
  15. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/agent_api.py +0 -0
  16. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/backends.py +0 -0
  17. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/capture.py +0 -0
  18. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/config.py +0 -0
  19. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/dashboard/__init__.py +0 -0
  20. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/dashboard/api.py +0 -0
  21. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/dashboard/media.py +0 -0
  22. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/dashboard/server.py +0 -0
  23. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/app.css +0 -0
  24. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/app.js +0 -0
  25. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/index.html +0 -0
  26. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/drafts.py +0 -0
  27. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/errors.py +0 -0
  28. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/ghcli.py +0 -0
  29. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/live.py +0 -0
  30. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/live_session.py +0 -0
  31. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/logs.py +0 -0
  32. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/mcp_server.py +0 -0
  33. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/paths.py +0 -0
  34. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/recorder.py +0 -0
  35. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/refs.py +0 -0
  36. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/repo.py +0 -0
  37. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/sync.py +0 -0
  38. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/tomlio.py +0 -0
  39. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap/validation.py +0 -0
  40. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap.egg-info/SOURCES.txt +0 -0
  41. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
  42. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap.egg-info/entry_points.txt +0 -0
  43. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap.egg-info/requires.txt +0 -0
  44. {bugcap-0.4.0 → bugcap-0.5.0}/src/bugcap.egg-info/top_level.txt +0 -0
  45. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_agent_api.py +0 -0
  46. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_backends.py +0 -0
  47. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_capture.py +0 -0
  48. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_dashboard.py +0 -0
  49. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_init_capture_list.py +0 -0
  50. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_live_headless.py +0 -0
  51. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_media.py +0 -0
  52. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_no_image_delete.py +0 -0
  53. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_record.py +0 -0
  54. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_setup.py +0 -0
  55. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_cli_triage.py +0 -0
  56. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_dashboard_api.py +0 -0
  57. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_dashboard_media.py +0 -0
  58. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_dashboard_mutations.py +0 -0
  59. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_dashboard_security.py +0 -0
  60. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_dashboard_ui_static.py +0 -0
  61. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_drafts.py +0 -0
  62. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_ghcli.py +0 -0
  63. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_github_media_sync.py +0 -0
  64. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_github_pull.py +0 -0
  65. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_github_sync.py +0 -0
  66. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_image_refs.py +0 -0
  67. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_ingest.py +0 -0
  68. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_ingest_url.py +0 -0
  69. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_mcp.py +0 -0
  70. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_mcp_logging.py +0 -0
  71. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_paths.py +0 -0
  72. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_recorder_argv.py +0 -0
  73. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_recorder_devices.py +0 -0
  74. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_recorder_media_real.py +0 -0
  75. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_recorder_run.py +0 -0
  76. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_refs.py +0 -0
  77. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_refs_validate_rewrite.py +0 -0
  78. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_regression_output.py +0 -0
  79. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_repo_config.py +0 -0
  80. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_repo_values.py +0 -0
  81. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_service_media.py +0 -0
  82. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_service_refs.py +0 -0
  83. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_store_migration.py +0 -0
  84. {bugcap-0.4.0 → bugcap-0.5.0}/tests/test_sync_consent.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugcap
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule.
5
5
  Author: Konrad Tagnon Amen ALAHASSA
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bugcap"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -152,12 +152,8 @@ def cmd_list(args: argparse.Namespace) -> int:
152
152
  return 0
153
153
 
154
154
 
155
- def cmd_show(args: argparse.Namespace) -> int:
156
- with Store() as store:
157
- report = resolve_report(store, args.id)
158
- if report is None:
159
- return 1
160
-
155
+ def print_report(report) -> None:
156
+ """The multi-line details of one report, as `bugcap show` prints them."""
161
157
  print(f"#{report.id} {report.title}")
162
158
  print(f" created_at: {report.created_at}")
163
159
  print(f" status: {report.status}")
@@ -181,6 +177,15 @@ def cmd_show(args: argparse.Namespace) -> int:
181
177
  print(f" - {tracker}: {ref}")
182
178
  else:
183
179
  print(" (not synced to any tracker yet)")
180
+
181
+
182
+ def cmd_show(args: argparse.Namespace) -> int:
183
+ with Store() as store:
184
+ report = resolve_report(store, args.id)
185
+ if report is None:
186
+ return 1
187
+
188
+ print_report(report)
184
189
  return 0
185
190
 
186
191
 
@@ -574,6 +579,8 @@ def cmd_attach(args: argparse.Namespace) -> int:
574
579
  print(f"Attached {shot_path} to report #{args.id}")
575
580
  elif note and added:
576
581
  print(f"Added note to report #{args.id}")
582
+ print()
583
+ print_report(current)
577
584
  return 1 if rejected or (note and not added) else 0
578
585
 
579
586
 
@@ -1,11 +1,13 @@
1
1
  """Bring existing images into the store: local paths, globs and http(s) URLs.
2
2
 
3
3
  Every input is validated by its magic bytes (never its extension or Content-Type), copied
4
- into the images directory under a fresh name (the original path is not kept), and written
5
- atomically. Network access is limited to plain http(s) GETs with no credentials."""
4
+ into the images directory under a name derived from its content (the original path is not
5
+ kept) and written atomically. The same image is therefore stored once, however many reports
6
+ use it. Network access is limited to plain http(s) GETs with no credentials."""
6
7
  from __future__ import annotations
7
8
 
8
9
  import glob
10
+ import hashlib
9
11
  import os
10
12
  import socket
11
13
  import urllib.error
@@ -32,6 +34,7 @@ class StoredFile:
32
34
  mime: str
33
35
  size_bytes: int
34
36
  source: str
37
+ reused: bool = False # the content was already in the store; no new file was written
35
38
 
36
39
 
37
40
  @dataclass
@@ -70,10 +73,16 @@ def sniff_image(data: bytes) -> str:
70
73
  raise ServiceError("invalid_image", "not an image (PNG, JPEG, GIF or WebP expected)")
71
74
 
72
75
 
76
+ def content_name(data: bytes, mime: str) -> str:
77
+ return f"{hashlib.sha256(data).hexdigest()}{_EXT[mime]}"
78
+
79
+
73
80
  def _store(data: bytes, source: str) -> StoredFile:
74
81
  mime = sniff_image(data)
75
- dest = images_dir() / f"{uuid.uuid4()}{_EXT[mime]}"
76
- tmp = dest.with_name(dest.name + ".part")
82
+ dest = images_dir() / content_name(data, mime)
83
+ if dest.is_file() and dest.stat().st_size == len(data):
84
+ return StoredFile(to_data_relative(dest), dest, mime, len(data), source, reused=True)
85
+ tmp = dest.with_name(f"{dest.name}.{uuid.uuid4().hex}.part")
77
86
  try:
78
87
  tmp.write_bytes(data)
79
88
  os.replace(tmp, dest)
@@ -82,6 +91,29 @@ def _store(data: bytes, source: str) -> StoredFile:
82
91
  return StoredFile(to_data_relative(dest), dest, mime, len(data), source)
83
92
 
84
93
 
94
+ def adopt(path) -> Path:
95
+ """Give a screenshot the capture tool already saved in the store its content-derived
96
+ name. If the same image is stored already, the new copy is dropped and the existing
97
+ path returned. Anything that is not a recognisable image, or sits outside the store, is
98
+ left where it is."""
99
+ path = Path(path)
100
+ if path.parent != images_dir():
101
+ return path
102
+ try:
103
+ data = path.read_bytes()
104
+ mime = sniff_image(data)
105
+ except (OSError, ServiceError):
106
+ return path
107
+ dest = images_dir() / content_name(data, mime)
108
+ if dest == path:
109
+ return path
110
+ if dest.is_file() and dest.stat().st_size == len(data):
111
+ path.unlink(missing_ok=True)
112
+ return dest
113
+ os.replace(path, dest)
114
+ return dest
115
+
116
+
85
117
  def import_local(path: Path) -> StoredFile:
86
118
  path = Path(path).expanduser()
87
119
  if path.is_dir():
@@ -81,6 +81,8 @@ class AddResult:
81
81
 
82
82
  def _discard(stored_files) -> None:
83
83
  for stored in stored_files:
84
+ if stored.reused: # shared with other media; not ours to delete
85
+ continue
84
86
  try:
85
87
  stored.abs_path.unlink()
86
88
  except OSError:
@@ -158,6 +160,7 @@ def create_report(
158
160
  with store.transaction():
159
161
  report = store.add(title=title, notes="", tags=tags, repo=repo, body=body, status=status)
160
162
  if captured is not None:
163
+ captured = str(ingest.adopt(captured))
161
164
  result.added.append(
162
165
  store.insert_media(report.id, kind="image", path=captured,
163
166
  label=captured_label or None, source="captured")
@@ -194,7 +197,7 @@ def delete_report(store: Store, report_id: int) -> Report:
194
197
  report = _require(store, report_id)
195
198
  store.delete(report_id)
196
199
  for item in report.media:
197
- _delete_files(item)
200
+ _delete_files(store, item)
198
201
  return report
199
202
 
200
203
 
@@ -202,6 +205,7 @@ def attach_captured(store: Store, report_id: int, path: str, label: Optional[str
202
205
  """Attach a screenshot the capture tool already saved in the store (no copy)."""
203
206
  report = _require(store, report_id)
204
207
  _check_new_labels(report.media, [label])
208
+ path = ingest.adopt(path)
205
209
  return store.insert_media(report_id, kind="image", path=str(path), label=label or None, source="captured")
206
210
 
207
211
 
@@ -343,14 +347,18 @@ def remove_media(store: Store, report_id: int, ref: str, force: bool = False) ->
343
347
  notes, count = refs.rewrite_references_counted(report.notes, mapping)
344
348
  if count:
345
349
  store.update(report_id, notes=notes)
346
- _delete_files(item)
350
+ _delete_files(store, item)
347
351
  return count
348
352
 
349
353
 
350
- def _delete_files(item: Media) -> None:
354
+ def _delete_files(store: Store, item: Media) -> None:
355
+ """Delete an item's files unless another media item still uses them (identical images
356
+ are stored once and shared). Call after the item's rows are gone."""
351
357
  paths = [item.path] if item.path else []
352
358
  paths += [f.path for f in item.frames]
353
359
  for rel in paths:
360
+ if store.path_in_use(rel):
361
+ continue
354
362
  try:
355
363
  os.unlink(resolve_data_path(rel))
356
364
  except (ValueError, OSError):
@@ -425,6 +425,14 @@ class Store:
425
425
  ).fetchall()
426
426
  return [self._load_media(r) for r in rows]
427
427
 
428
+ def path_in_use(self, path: str) -> bool:
429
+ """Whether any media item or frame still points at this stored file."""
430
+ row = self._conn.execute(
431
+ "SELECT EXISTS(SELECT 1 FROM media WHERE path = ?) OR EXISTS(SELECT 1 FROM media_frames WHERE path = ?)",
432
+ (path, path),
433
+ ).fetchone()
434
+ return bool(row[0])
435
+
428
436
  def get_media(self, media_id: int) -> Optional[Media]:
429
437
  row = self._conn.execute("SELECT * FROM media WHERE id = ?", (media_id,)).fetchone()
430
438
  return self._load_media(row) if row else None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugcap
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule.
5
5
  Author: Konrad Tagnon Amen ALAHASSA
6
6
  License: MIT
@@ -169,3 +169,77 @@ def test_pull_ask_flow_appends_note(bugcap_home, monkeypatch, tmp_path):
169
169
  sync._attach_answer(store, rid, None)
170
170
  report = store.get(rid)
171
171
  assert len(report.media) == 2 and report.notes.endswith("@i1: seen on pull")
172
+
173
+
174
+ # --- details after attach / shared storage -----------------------------------------------
175
+
176
+ def test_attach_prints_the_bug_details_after_the_usual_output(bugcap_home, tmp_path, capsys):
177
+ with Store() as store:
178
+ rid = store.add("Login fails", tags=["auth"]).id
179
+ img = tmp_path / "new.png"
180
+ img.write_bytes(png_bytes())
181
+ assert cli.main(["attach", str(rid), "--image", str(img), "--note", "see this"]) == 0
182
+ out = capsys.readouterr().out
183
+ assert out.index("added @i1") < out.index(f"#{rid} Login fails")
184
+ assert "tags: auth" in out and "see this" in out and "media:" in out and "i1" in out
185
+
186
+
187
+ def test_attach_with_a_rejected_image_still_shows_the_unchanged_bug(bugcap_home, tmp_path, capsys):
188
+ with Store() as store:
189
+ rid = store.add("Bug").id
190
+ assert cli.main(["attach", str(rid), "--image", str(tmp_path / "missing.png")]) == 1
191
+ captured = capsys.readouterr()
192
+ assert "skipped" in captured.err and f"#{rid} Bug" in captured.out and "media:" not in captured.out
193
+
194
+
195
+ def test_attach_unknown_id_prints_no_details(bugcap_home, capsys):
196
+ assert cli.main(["attach", "999", "--image", "x.png"]) == 1
197
+ assert capsys.readouterr().out == ""
198
+
199
+
200
+ def test_same_image_on_two_bugs_is_stored_once(bugcap_home, tmp_path):
201
+ from bugcap.paths import images_dir
202
+
203
+ with Store() as store:
204
+ a, b = store.add("A").id, store.add("B").id
205
+ img = tmp_path / "same.png"
206
+ img.write_bytes(png_bytes())
207
+ assert cli.main(["attach", str(a), "--image", str(img)]) == 0
208
+ assert cli.main(["attach", str(b), "--image", str(img)]) == 0
209
+ with Store() as store:
210
+ pa, pb = store.get(a).media[0].path, store.get(b).media[0].path
211
+ assert pa == pb and len(list(images_dir().iterdir())) == 1
212
+
213
+
214
+ def test_shared_file_survives_until_its_last_user_is_gone(bugcap_home, tmp_path):
215
+ from bugcap import service
216
+
217
+ with Store() as store:
218
+ a, b = store.add("A").id, store.add("B").id
219
+ img = tmp_path / "same.png"
220
+ img.write_bytes(png_bytes())
221
+ for rid in (a, b):
222
+ assert cli.main(["attach", str(rid), "--image", str(img)]) == 0
223
+ with Store() as store:
224
+ path = store.get(a).media[0].abs_path
225
+ service.remove_media(store, a, "i1")
226
+ assert __import__("os").path.isfile(path) # B still uses it
227
+ service.delete_report(store, b)
228
+ assert not __import__("os").path.exists(path)
229
+
230
+
231
+ def test_same_image_twice_in_one_attach_and_failed_attach_keep_shared_file(bugcap_home, tmp_path):
232
+ from bugcap import service
233
+
234
+ with Store() as store:
235
+ a, b = store.add("A").id, store.add("B").id
236
+ img = tmp_path / "same.png"
237
+ img.write_bytes(png_bytes())
238
+ service.add_media(store, a, [str(img)])
239
+ # B asks for the same image with a label that cannot be used: nothing may be deleted
240
+ with pytest.raises(Exception):
241
+ service.add_media(store, b, [str(img)], labels=["i1"])
242
+ path = store.get(a).media[0].abs_path
243
+ assert __import__("os").path.isfile(path)
244
+ res = service.add_media(store, b, [str(img), str(img)])
245
+ assert len(res.added) == 2 and len({m.path for m in res.added}) == 1
@@ -97,7 +97,7 @@ def test_save_combines_images_and_a_video_in_order(session):
97
97
  assert report.title == "Login broken" and report.repo == "owner/proj"
98
98
  assert report.tags == ["auth", "proj"] and report.status == "in-progress"
99
99
  assert [(m.idx, m.kind) for m in report.media] == [(1, "image"), (2, "video"), (3, "image")]
100
- assert first.exists() # captured files live on in the store, not copied away
100
+ assert all(Path(m.abs_path).is_file() for m in report.media) # captured files stay in the store
101
101
 
102
102
 
103
103
  def test_two_reports_in_a_row(session):
@@ -1 +0,0 @@
1
- __version__ = "0.4.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes