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/store.py ADDED
@@ -0,0 +1,485 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from .paths import absolute_stored_path, db_path, to_data_relative
12
+
13
+ SCHEMA_VERSION = 2
14
+
15
+ MEDIA_KINDS = ("image", "video", "frames", "animated")
16
+
17
+ _MIME = {
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".gif": "image/gif",
22
+ ".webp": "image/webp",
23
+ ".bmp": "image/bmp",
24
+ ".mp4": "video/mp4",
25
+ ".webm": "video/webm",
26
+ }
27
+
28
+
29
+ def guess_mime(path) -> str:
30
+ return _MIME.get(Path(str(path)).suffix.lower(), "application/octet-stream")
31
+
32
+
33
+ # Added in schema version 2 (created by _migrate for fresh and upgraded databases).
34
+ MEDIA_SCHEMA = """
35
+ CREATE TABLE IF NOT EXISTS media (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ report_id INTEGER NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
38
+ idx INTEGER NOT NULL CHECK (idx >= 1),
39
+ label TEXT,
40
+ kind TEXT NOT NULL CHECK (kind IN ('image','video','frames','animated')),
41
+ path TEXT,
42
+ mime TEXT NOT NULL,
43
+ size_bytes INTEGER NOT NULL DEFAULT 0,
44
+ source TEXT,
45
+ created_at TEXT NOT NULL,
46
+ UNIQUE (report_id, idx)
47
+ );
48
+ CREATE UNIQUE INDEX IF NOT EXISTS media_label_unique
49
+ ON media (report_id, lower(label)) WHERE label IS NOT NULL;
50
+ CREATE TABLE IF NOT EXISTS media_frames (
51
+ media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
52
+ frame_no INTEGER NOT NULL,
53
+ path TEXT NOT NULL,
54
+ size_bytes INTEGER NOT NULL DEFAULT 0,
55
+ PRIMARY KEY (media_id, frame_no)
56
+ );
57
+ """
58
+
59
+ # `reports` table in its version 1 shape; _migrate() brings every database to SCHEMA_VERSION
60
+ # (adding the media tables).
61
+ SCHEMA = """
62
+ CREATE TABLE IF NOT EXISTS reports (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ created_at TEXT NOT NULL,
65
+ title TEXT NOT NULL,
66
+ body TEXT NOT NULL DEFAULT '',
67
+ notes TEXT NOT NULL DEFAULT '',
68
+ image_paths TEXT NOT NULL DEFAULT '[]',
69
+ tags TEXT NOT NULL DEFAULT '[]',
70
+ status TEXT NOT NULL DEFAULT 'open',
71
+ repo TEXT,
72
+ synced_refs TEXT NOT NULL DEFAULT '{}'
73
+ );
74
+ """
75
+
76
+ # Allowed statuses for `edit --status`; free-text legacy values are tolerated on read.
77
+ STATUSES = ("open", "in-progress", "resolved", "closed", "wontfix")
78
+
79
+
80
+ def normalize_tags(tags) -> list[str]:
81
+ """Trim, drop empties, de-duplicate while preserving order and case."""
82
+ seen: dict[str, None] = {}
83
+ for tag in tags or []:
84
+ cleaned = str(tag).strip()
85
+ if cleaned and cleaned not in seen:
86
+ seen[cleaned] = None
87
+ return list(seen)
88
+
89
+
90
+ @dataclass
91
+ class MediaFrame:
92
+ media_id: int
93
+ frame_no: int
94
+ path: str
95
+ size_bytes: int = 0
96
+
97
+
98
+ @dataclass
99
+ class Media:
100
+ id: int
101
+ report_id: int
102
+ idx: int
103
+ label: Optional[str]
104
+ kind: str
105
+ path: Optional[str]
106
+ mime: str
107
+ size_bytes: int
108
+ source: Optional[str]
109
+ created_at: str
110
+ frames: list[MediaFrame] = field(default_factory=list)
111
+
112
+ @property
113
+ def abs_path(self) -> Optional[str]:
114
+ return absolute_stored_path(self.path) if self.path else None
115
+
116
+ @classmethod
117
+ def from_row(cls, row: sqlite3.Row) -> "Media":
118
+ return cls(
119
+ id=row["id"],
120
+ report_id=row["report_id"],
121
+ idx=row["idx"],
122
+ label=row["label"],
123
+ kind=row["kind"],
124
+ path=row["path"],
125
+ mime=row["mime"],
126
+ size_bytes=row["size_bytes"],
127
+ source=row["source"],
128
+ created_at=row["created_at"],
129
+ )
130
+
131
+
132
+ @dataclass
133
+ class Report:
134
+ id: Optional[int]
135
+ created_at: str
136
+ title: str
137
+ body: str = ""
138
+ notes: str = ""
139
+ image_paths: list[str] = field(default_factory=list)
140
+ tags: list[str] = field(default_factory=list)
141
+ status: str = "open"
142
+ repo: Optional[str] = None
143
+ synced_refs: dict[str, str] = field(default_factory=dict)
144
+ media: list[Media] = field(default_factory=list)
145
+
146
+ @classmethod
147
+ def from_row(cls, row: sqlite3.Row) -> "Report":
148
+ return cls(
149
+ id=row["id"],
150
+ created_at=row["created_at"],
151
+ title=row["title"],
152
+ body=row["body"] if "body" in row.keys() else "",
153
+ notes=row["notes"],
154
+ image_paths=[], # compatibility view, filled from media by Store._hydrate
155
+ tags=json.loads(row["tags"]),
156
+ status=row["status"],
157
+ repo=row["repo"] if "repo" in row.keys() else None,
158
+ synced_refs=json.loads(row["synced_refs"]),
159
+ )
160
+
161
+
162
+ class Store:
163
+ def __init__(self, path: Optional[Path] = None):
164
+ self.path = path or db_path()
165
+ self._conn = sqlite3.connect(self.path)
166
+ self._conn.row_factory = sqlite3.Row
167
+ self._conn.execute("PRAGMA foreign_keys = ON")
168
+ self._conn.execute(SCHEMA)
169
+ self._depth = 0
170
+ self._migrate()
171
+ self._conn.commit()
172
+
173
+ # --- transactions ----------------------------------------------------------
174
+
175
+ def _commit(self) -> None:
176
+ if self._depth == 0:
177
+ self._conn.commit()
178
+
179
+ @contextmanager
180
+ def transaction(self):
181
+ """Group several store calls into one atomic unit (re-entrant)."""
182
+ if self._depth == 0:
183
+ self._conn.commit()
184
+ self._conn.execute("BEGIN")
185
+ self._depth += 1
186
+ try:
187
+ yield self
188
+ except BaseException:
189
+ self._depth -= 1
190
+ if self._depth == 0:
191
+ self._conn.execute("ROLLBACK")
192
+ raise
193
+ else:
194
+ self._depth -= 1
195
+ if self._depth == 0:
196
+ self._conn.execute("COMMIT")
197
+
198
+ # --- migration -------------------------------------------------------------
199
+
200
+ def _migrate(self) -> None:
201
+ """PRAGMA user_version-based migration to SCHEMA_VERSION, idempotent and row-preserving."""
202
+ version = self._conn.execute("PRAGMA user_version").fetchone()[0]
203
+ if version >= SCHEMA_VERSION:
204
+ return
205
+ columns = {r["name"] for r in self._conn.execute("PRAGMA table_info(reports)")}
206
+ try:
207
+ self._conn.execute("BEGIN")
208
+ if version < 1:
209
+ if "repo" not in columns:
210
+ self._conn.execute("ALTER TABLE reports ADD COLUMN repo TEXT")
211
+ if "body" not in columns:
212
+ self._conn.execute("ALTER TABLE reports ADD COLUMN body TEXT NOT NULL DEFAULT ''")
213
+ if version < 2:
214
+ self._migrate_media()
215
+ self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
216
+ self._conn.execute("COMMIT")
217
+ except Exception:
218
+ self._conn.execute("ROLLBACK")
219
+ raise
220
+
221
+ def _migrate_media(self) -> None:
222
+ """v1 -> v2: create the media tables and copy each legacy image_paths entry into them.
223
+ The image_paths column is left untouched."""
224
+ columns = {r["name"] for r in self._conn.execute("PRAGMA table_info(reports)")}
225
+ if "media_seq" not in columns: # high-water mark: media indexes are never reused
226
+ self._conn.execute("ALTER TABLE reports ADD COLUMN media_seq INTEGER NOT NULL DEFAULT 0")
227
+ for statement in MEDIA_SCHEMA.split(";"):
228
+ if statement.strip():
229
+ self._conn.execute(statement)
230
+ rows = self._conn.execute("SELECT id, created_at, image_paths FROM reports").fetchall()
231
+ for row in rows:
232
+ try:
233
+ paths = json.loads(row["image_paths"] or "[]")
234
+ except ValueError:
235
+ paths = []
236
+ for position, legacy in enumerate(paths):
237
+ legacy = str(legacy)
238
+ try:
239
+ size = Path(legacy).stat().st_size
240
+ except OSError:
241
+ size = 0
242
+ self._conn.execute(
243
+ "INSERT INTO media (report_id, idx, kind, path, mime, size_bytes, source, created_at) "
244
+ "VALUES (?, ?, 'image', ?, ?, ?, 'legacy', ?)",
245
+ (row["id"], position + 1, to_data_relative(legacy), guess_mime(legacy), size, row["created_at"]),
246
+ )
247
+ if paths:
248
+ self._conn.execute("UPDATE reports SET media_seq = ? WHERE id = ?", (len(paths), row["id"]))
249
+
250
+ def close(self) -> None:
251
+ self._conn.close()
252
+
253
+ def __enter__(self) -> "Store":
254
+ return self
255
+
256
+ def __exit__(self, *exc) -> None:
257
+ self.close()
258
+
259
+ # --- reports ---------------------------------------------------------------
260
+
261
+ def _hydrate(self, row: sqlite3.Row) -> Report:
262
+ report = Report.from_row(row)
263
+ report.media = self.list_media(report.id)
264
+ report.image_paths = [m.abs_path for m in report.media if m.kind == "image" and m.path]
265
+ return report
266
+
267
+ def add(
268
+ self,
269
+ title: str,
270
+ notes: str = "",
271
+ image_paths: Optional[list[str]] = None,
272
+ tags: Optional[list[str]] = None,
273
+ repo: Optional[str] = None,
274
+ body: str = "",
275
+ status: str = "open",
276
+ synced_refs: Optional[dict[str, str]] = None,
277
+ ) -> Report:
278
+ if not (title or "").strip():
279
+ raise ValueError("title must not be empty")
280
+ created_at = datetime.now(timezone.utc).isoformat()
281
+ with self.transaction():
282
+ cur = self._conn.execute(
283
+ "INSERT INTO reports "
284
+ "(created_at, title, body, notes, image_paths, tags, status, repo, synced_refs) "
285
+ "VALUES (?, ?, ?, ?, '[]', ?, ?, ?, ?)",
286
+ (
287
+ created_at,
288
+ title,
289
+ body,
290
+ notes,
291
+ json.dumps(normalize_tags(tags)),
292
+ status,
293
+ repo,
294
+ json.dumps(synced_refs or {}),
295
+ ),
296
+ )
297
+ report_id = cur.lastrowid
298
+ for path in image_paths or []:
299
+ self.insert_media(report_id, kind="image", path=str(path), source="legacy")
300
+ return self.get(report_id)
301
+
302
+ def get(self, report_id: int) -> Optional[Report]:
303
+ row = self._conn.execute(
304
+ "SELECT * FROM reports WHERE id = ?", (report_id,)
305
+ ).fetchone()
306
+ return self._hydrate(row) if row else None
307
+
308
+ def list(self, repo: Optional[str] = None) -> list[Report]:
309
+ if repo is None:
310
+ rows = self._conn.execute("SELECT * FROM reports ORDER BY id DESC").fetchall()
311
+ else:
312
+ rows = self._conn.execute(
313
+ "SELECT * FROM reports WHERE repo = ? ORDER BY id DESC", (repo,)
314
+ ).fetchall()
315
+ return [self._hydrate(row) for row in rows]
316
+
317
+ def update(
318
+ self,
319
+ report_id: int,
320
+ *,
321
+ title: Optional[str] = None,
322
+ notes: Optional[str] = None,
323
+ status: Optional[str] = None,
324
+ body: Optional[str] = None,
325
+ ) -> Optional[Report]:
326
+ fields: dict[str, str] = {}
327
+ if title is not None:
328
+ if not title.strip():
329
+ raise ValueError("title must not be empty")
330
+ fields["title"] = title
331
+ if notes is not None:
332
+ fields["notes"] = notes
333
+ if body is not None:
334
+ fields["body"] = body
335
+ if status is not None:
336
+ if status not in STATUSES:
337
+ raise ValueError(
338
+ f"invalid status {status!r}; choose one of: {', '.join(STATUSES)}"
339
+ )
340
+ fields["status"] = status
341
+ if not fields:
342
+ return self.get(report_id)
343
+ assignments = ", ".join(f"{k} = ?" for k in fields)
344
+ self._conn.execute(
345
+ f"UPDATE reports SET {assignments} WHERE id = ?",
346
+ (*fields.values(), report_id),
347
+ )
348
+ self._commit()
349
+ return self.get(report_id)
350
+
351
+ def set_tags(self, report_id: int, tags: list[str]) -> Optional[Report]:
352
+ self._conn.execute(
353
+ "UPDATE reports SET tags = ? WHERE id = ?",
354
+ (json.dumps(normalize_tags(tags)), report_id),
355
+ )
356
+ self._commit()
357
+ return self.get(report_id)
358
+
359
+ def add_image(self, report_id: int, path: str, source: Optional[str] = None) -> Optional[Report]:
360
+ if self.get(report_id) is None:
361
+ return None
362
+ self.insert_media(report_id, kind="image", path=str(path), source=source)
363
+ return self.get(report_id)
364
+
365
+ def set_ref(self, report_id: int, key: str, value: str) -> Optional[Report]:
366
+ report = self.get(report_id)
367
+ if report is None:
368
+ return None
369
+ refs = dict(report.synced_refs)
370
+ refs[key] = value
371
+ self._conn.execute(
372
+ "UPDATE reports SET synced_refs = ? WHERE id = ?",
373
+ (json.dumps(refs), report_id),
374
+ )
375
+ self._commit()
376
+ return self.get(report_id)
377
+
378
+ def move_repo(self, old_key: str, new_key: str, old_tag: Optional[str], new_tag: Optional[str]) -> int:
379
+ """Re-home every report stored under `old_key`: set its repo to `new_key` and, when the
380
+ tag changed, swap `old_tag` for `new_tag` in its tags. Returns the number of reports."""
381
+ with self.transaction():
382
+ reports = self.list(repo=old_key)
383
+ for report in reports:
384
+ tags = list(report.tags)
385
+ if old_tag and new_tag and old_tag != new_tag and old_tag in tags:
386
+ tags[tags.index(old_tag)] = new_tag
387
+ self._conn.execute(
388
+ "UPDATE reports SET repo = ?, tags = ? WHERE id = ?",
389
+ (new_key, json.dumps(normalize_tags(tags)), report.id),
390
+ )
391
+ return len(reports)
392
+
393
+ def find_by_ref(self, key: str, value: str) -> Optional[Report]:
394
+ for report in self.list():
395
+ if report.synced_refs.get(key) == value:
396
+ return report
397
+ return None
398
+
399
+ # --- media -----------------------------------------------------------------
400
+
401
+ def _load_media(self, row: sqlite3.Row) -> Media:
402
+ media = Media.from_row(row)
403
+ if media.kind == "frames":
404
+ media.frames = [
405
+ MediaFrame(r["media_id"], r["frame_no"], r["path"], r["size_bytes"])
406
+ for r in self._conn.execute(
407
+ "SELECT * FROM media_frames WHERE media_id = ? ORDER BY frame_no", (media.id,)
408
+ )
409
+ ]
410
+ return media
411
+
412
+ def list_media(self, report_id: int) -> list[Media]:
413
+ rows = self._conn.execute(
414
+ "SELECT * FROM media WHERE report_id = ? ORDER BY idx", (report_id,)
415
+ ).fetchall()
416
+ return [self._load_media(r) for r in rows]
417
+
418
+ def get_media(self, media_id: int) -> Optional[Media]:
419
+ row = self._conn.execute("SELECT * FROM media WHERE id = ?", (media_id,)).fetchone()
420
+ return self._load_media(row) if row else None
421
+
422
+ def insert_media(
423
+ self,
424
+ report_id: int,
425
+ *,
426
+ kind: str = "image",
427
+ path: Optional[str] = None,
428
+ label: Optional[str] = None,
429
+ mime: Optional[str] = None,
430
+ size_bytes: Optional[int] = None,
431
+ source: Optional[str] = None,
432
+ frames: Optional[list[tuple[str, int]]] = None,
433
+ ) -> Media:
434
+ """Insert a media item at the next never-used index (computed inside the transaction)."""
435
+ if kind not in MEDIA_KINDS:
436
+ raise ValueError(f"invalid media kind {kind!r}")
437
+ stored = to_data_relative(path) if path else None
438
+ if size_bytes is None:
439
+ try:
440
+ size_bytes = Path(path).stat().st_size if path else 0
441
+ except OSError:
442
+ size_bytes = 0
443
+ if mime is None:
444
+ mime = guess_mime(path) if path else "image/png"
445
+ created_at = datetime.now(timezone.utc).isoformat()
446
+ with self.transaction():
447
+ idx = self._next_index(report_id)
448
+ cur = self._conn.execute(
449
+ "INSERT INTO media (report_id, idx, label, kind, path, mime, size_bytes, source, created_at) "
450
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
451
+ (report_id, idx, label, kind, stored, mime, size_bytes, source, created_at),
452
+ )
453
+ media_id = cur.lastrowid
454
+ if frames:
455
+ self.insert_frames(media_id, frames)
456
+ return self.get_media(media_id)
457
+
458
+ def _next_index(self, report_id: int) -> int:
459
+ """Next index: above both the current maximum and the report's high-water mark, so an
460
+ index is never reused, even after the item that had it was removed."""
461
+ row = self._conn.execute(
462
+ "SELECT COALESCE(MAX(idx), 0), (SELECT media_seq FROM reports WHERE id = ?) "
463
+ "FROM media WHERE report_id = ?", (report_id, report_id),
464
+ ).fetchone()
465
+ idx = max(row[0], row[1] or 0) + 1
466
+ self._conn.execute("UPDATE reports SET media_seq = ? WHERE id = ?", (idx, report_id))
467
+ return idx
468
+
469
+ def update_media_label(self, media_id: int, label: Optional[str]) -> None:
470
+ self._conn.execute("UPDATE media SET label = ? WHERE id = ?", (label, media_id))
471
+ self._commit()
472
+
473
+ def delete_media(self, media_id: int) -> None:
474
+ self._conn.execute("DELETE FROM media_frames WHERE media_id = ?", (media_id,))
475
+ self._conn.execute("DELETE FROM media WHERE id = ?", (media_id,))
476
+ self._commit()
477
+
478
+ def insert_frames(self, media_id: int, frames: list[tuple[str, int]]) -> None:
479
+ """frames: [(path, size_bytes)] in order; frame_no is 1-based."""
480
+ for number, (path, size) in enumerate(frames, start=1):
481
+ self._conn.execute(
482
+ "INSERT INTO media_frames (media_id, frame_no, path, size_bytes) VALUES (?, ?, ?, ?)",
483
+ (media_id, number, to_data_relative(path), size),
484
+ )
485
+ self._commit()