transcript-viewer 0.16.0__tar.gz → 0.16.1__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 (53) hide show
  1. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/PKG-INFO +1 -1
  2. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/pyproject.toml +1 -1
  3. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/db.py +21 -5
  4. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/library.py +21 -14
  5. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_db.py +33 -1
  6. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/uv.lock +1 -1
  7. transcript_viewer-0.16.0/postgresql:/postgres:x@localhost/postgres +0 -0
  8. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.coverage +0 -0
  9. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.github/workflows/publish.yml +0 -0
  10. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.github/workflows/test.yml +0 -0
  11. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.gitignore +0 -0
  12. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/README.md +0 -0
  13. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/TODO.md +0 -0
  14. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Anchor.dc.html +0 -0
  15. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Dense.dc.html +0 -0
  16. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Hover.dc.html +0 -0
  17. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Main.dc.html +0 -0
  18. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Quiet.dc.html +0 -0
  19. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/canvas.json +0 -0
  20. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/delegation-timeline.html +0 -0
  21. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/__init__.py +0 -0
  22. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/ai.py +0 -0
  23. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/cache.py +0 -0
  24. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/cli.py +0 -0
  25. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/config.py +0 -0
  26. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/corpus.py +0 -0
  27. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/deploy.py +0 -0
  28. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/fetch.py +0 -0
  29. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/identity.py +0 -0
  30. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/ingest.py +0 -0
  31. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/page.html +0 -0
  32. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/store.py +0 -0
  33. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/viewer.py +0 -0
  34. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/fixtures/big-command.json +0 -0
  35. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/page.test.js +0 -0
  36. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_ai.py +0 -0
  37. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_authorisation.py +0 -0
  38. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_cache.py +0 -0
  39. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_cli.py +0 -0
  40. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_config.py +0 -0
  41. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_corpus.py +0 -0
  42. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_deploy.py +0 -0
  43. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_fetch.py +0 -0
  44. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_identity.py +0 -0
  45. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_library.py +0 -0
  46. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_page.py +0 -0
  47. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_readme.py +0 -0
  48. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_store.py +0 -0
  49. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_stream.py +0 -0
  50. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_style.py +0 -0
  51. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_sync.py +0 -0
  52. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_tidiness.py +0 -0
  53. {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_viewer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: transcript-viewer
3
- Version: 0.16.0
3
+ Version: 0.16.1
4
4
  Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transcript-viewer"
3
- version = "0.16.0"
3
+ version = "0.16.1"
4
4
  description = "Browse agent transcripts in a local, dependency-free web viewer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -103,6 +103,11 @@ def now() -> str:
103
103
  return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
104
104
 
105
105
 
106
+ def _where(value: Path | str) -> str:
107
+ """A store's address as written, whether it arrived as a path or a URL."""
108
+ return value if isinstance(value, str) else str(value)
109
+
110
+
106
111
  def _postgres(url: str) -> bool:
107
112
  return url.startswith(("postgres://", "postgresql://"))
108
113
 
@@ -209,10 +214,20 @@ def _open(where: str) -> Store:
209
214
  return Store(conn, postgres=False)
210
215
 
211
216
 
212
- def connect(path: Path | None = None) -> Store:
213
- """The store for this thread, opened and set up on first use."""
217
+ def connect(where: Path | str | None = None) -> Store:
218
+ """The store for this thread, opened and set up on first use.
219
+
220
+ A path or a URL. Both, because a caller that has one should not have to
221
+ know which the other would be — and wrapping a URL in `Path` to satisfy a
222
+ narrower type silently destroys it: `Path` collapses the `//` in
223
+ `postgresql://` to one slash, which stops looking like a URL and starts
224
+ looking like a relative filename. That produced a SQLite file named after
225
+ the database it was supposed to be, and a test comparing the two engines
226
+ that was quietly comparing SQLite with itself.
227
+ """
214
228
  where = (
215
- str(path) if path is not None else (deploy.current().database or str(DB_PATH))
229
+ _where(where) if where is not None
230
+ else (deploy.current().database or str(DB_PATH))
216
231
  )
217
232
  have = getattr(_local, "conns", None)
218
233
  if have is None:
@@ -268,10 +283,11 @@ def _prepare(conn: Store) -> None:
268
283
  conn._conn.commit()
269
284
 
270
285
 
271
- def close(path: Path | None = None) -> None:
286
+ def close(where: Path | str | None = None) -> None:
272
287
  """Drop this thread's connection. Tests reopen against a new store."""
273
288
  where = (
274
- str(path) if path is not None else (deploy.current().database or str(DB_PATH))
289
+ _where(where) if where is not None
290
+ else (deploy.current().database or str(DB_PATH))
275
291
  )
276
292
  conn = (getattr(_local, "conns", None) or {}).pop(where, None)
277
293
  if conn is not None:
@@ -134,7 +134,7 @@ def _clean(name: str, value: Any) -> Any:
134
134
  return str(value).strip()[:500] if value is not None else ""
135
135
 
136
136
 
137
- def load(path: Path | None = None) -> dict[str, dict]:
137
+ def load(path: Path | str | None = None) -> dict[str, dict]:
138
138
  """Every annotation, by key.
139
139
 
140
140
  Two queries and one pass, because this answers a whole page load. Asking per
@@ -151,7 +151,7 @@ def load(path: Path | None = None) -> dict[str, dict]:
151
151
  return by_key
152
152
 
153
153
 
154
- def get(key: str, path: Path | None = None) -> dict:
154
+ def get(key: str, path: Path | str | None = None) -> dict:
155
155
  """One record, with defaults filled in, whether or not it is annotated."""
156
156
  conn = db.connect(path)
157
157
  row = conn.execute(f"{_READ} WHERE t.content_hash = ?", (ME, key)).fetchone()
@@ -168,7 +168,7 @@ def get(key: str, path: Path | None = None) -> dict:
168
168
  )
169
169
 
170
170
 
171
- def update(key: str, path: Path | None = None, **fields: Any) -> dict:
171
+ def update(key: str, path: Path | str | None = None, **fields: Any) -> dict:
172
172
  """Merge fields into one record and persist. Returns the merged record."""
173
173
  if not key:
174
174
  raise ValueError("a key is required")
@@ -251,30 +251,37 @@ def _forget_if_bare(conn: Any, key: str) -> None:
251
251
  ).fetchone()
252
252
  if row is None or row["source"] or row["into_dir"]:
253
253
  return
254
+ # Positional, and the key given three times. Named placeholders are spelled
255
+ # differently by every driver, and one style throughout is what keeps the
256
+ # translation to a single substitution.
257
+ # Every column is named and read by name. SQLite's rows answer to a number
258
+ # as well; PostgreSQL's, as this reads them, do not — so a positional read
259
+ # works until the day it is asked of the other engine.
254
260
  held = conn.execute(
255
- "SELECT EXISTS(SELECT 1 FROM labels WHERE content_hash = :k) "
256
- " OR EXISTS(SELECT 1 FROM tags WHERE content_hash = :k) "
257
- " OR EXISTS(SELECT 1 FROM marks WHERE content_hash = :k)",
258
- {"k": key},
259
- ).fetchone()[0]
261
+ "SELECT EXISTS(SELECT 1 FROM labels WHERE content_hash = ?) "
262
+ " OR EXISTS(SELECT 1 FROM tags WHERE content_hash = ?) "
263
+ " OR EXISTS(SELECT 1 FROM marks WHERE content_hash = ?) AS held",
264
+ (key, key, key),
265
+ ).fetchone()["held"]
260
266
  if not held:
261
267
  conn.execute("DELETE FROM transcripts WHERE content_hash = ?", (key,))
262
268
 
263
269
 
264
- def remove(key: str, path: Path | None = None) -> bool:
270
+ def remove(key: str, path: Path | str | None = None) -> bool:
265
271
  """Forget one record. True when there was something to forget."""
266
272
  conn = db.connect(path)
267
273
  with _lock, conn:
268
274
  conn.begin()
269
275
  existed = conn.execute(
270
- "SELECT EXISTS(SELECT 1 FROM transcripts WHERE content_hash = ?)", (key,)
271
- ).fetchone()[0]
276
+ "SELECT EXISTS(SELECT 1 FROM transcripts WHERE content_hash = ?) AS there",
277
+ (key,),
278
+ ).fetchone()["there"]
272
279
  for table in ("labels", "tags", "marks", "transcripts"):
273
280
  conn.execute(f"DELETE FROM {table} WHERE content_hash = ?", (key,))
274
281
  return bool(existed)
275
282
 
276
283
 
277
- def clear(path: Path | None = None) -> None:
284
+ def clear(path: Path | str | None = None) -> None:
278
285
  """Forget every record. What the library's own clear-all is made of."""
279
286
  conn = db.connect(path)
280
287
  with _lock, conn:
@@ -283,7 +290,7 @@ def clear(path: Path | None = None) -> None:
283
290
  conn.execute(f"DELETE FROM {table}")
284
291
 
285
292
 
286
- def tags(path: Path | None = None) -> list[tuple[str, int]]:
293
+ def tags(path: Path | str | None = None) -> list[tuple[str, int]]:
287
294
  """Every tag with a count, most used first."""
288
295
  return [
289
296
  (row["tag"], row["n"])
@@ -293,7 +300,7 @@ def tags(path: Path | None = None) -> list[tuple[str, int]]:
293
300
  ]
294
301
 
295
302
 
296
- def decorate(rows: list[dict], path: Path | None = None) -> list[dict]:
303
+ def decorate(rows: list[dict], path: Path | str | None = None) -> list[dict]:
297
304
  """Fold annotations into index rows so a client gets one object per session."""
298
305
  entries = load(path)
299
306
  for row in rows:
@@ -387,7 +387,7 @@ def test_postgresql_and_sqlite_give_the_same_answers(tmp_path):
387
387
  TRANSCRIPT_VIEWER_TEST_DATABASE=postgresql://postgres:x@localhost/postgres \\
388
388
  uv run --extra parquet --extra postgres pytest -q tests/test_db.py
389
389
  """
390
- url = Path(os.environ["TRANSCRIPT_VIEWER_TEST_DATABASE"])
390
+ url = os.environ["TRANSCRIPT_VIEWER_TEST_DATABASE"]
391
391
 
392
392
  conn = db.connect(url)
393
393
  for table in ("labels", "tags", "marks", "transcripts", "schema_version"):
@@ -407,3 +407,35 @@ def test_postgresql_and_sqlite_give_the_same_answers(tmp_path):
407
407
  assert on_sqlite["written"]["tags"] == ["b", "a"], "insertion order, not sorted"
408
408
  assert on_sqlite["and once nothing is left"] == {}
409
409
 
410
+
411
+
412
+ def test_a_database_url_is_not_turned_into_a_filename():
413
+ """`Path("postgresql://x")` collapses the `//` to one slash, which stops
414
+ looking like a URL and starts looking like a relative filename. That opened
415
+ a SQLite file named after the database it was meant to be — and made a test
416
+ comparing the two engines compare SQLite with itself, passing."""
417
+ url = "postgresql://postgres:x@localhost/postgres"
418
+
419
+ assert db._where(url) == url
420
+ assert db._postgres(db._where(url))
421
+ # And the shape that caused it, so the coercion cannot come back.
422
+ assert not db._postgres(str(Path(url)))
423
+
424
+
425
+ def test_only_one_kind_of_placeholder_is_used():
426
+ """The translation into PostgreSQL is a single substitution of `?`. A named
427
+ placeholder passes straight through it and reaches the driver as a colon
428
+ the server does not understand — which is what `:k` did, in the one query
429
+ that decides whether a record is still worth keeping.
430
+ """
431
+ import re
432
+ from pathlib import Path
433
+
434
+ root = Path(__file__).resolve().parent.parent / "src/transcript_viewer"
435
+ for name in ("db.py", "library.py"):
436
+ for line in (root / name).read_text().splitlines():
437
+ if "SELECT" not in line and "INSERT" not in line and "DELETE" not in line:
438
+ continue
439
+ assert not re.search(r"= :\w", line), (
440
+ f"{name} uses a named placeholder: {line.strip()}"
441
+ )
@@ -452,7 +452,7 @@ wheels = [
452
452
 
453
453
  [[package]]
454
454
  name = "transcript-viewer"
455
- version = "0.16.0"
455
+ version = "0.16.1"
456
456
  source = { editable = "." }
457
457
  dependencies = [
458
458
  { name = "atif-make" },