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.
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/PKG-INFO +1 -1
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/pyproject.toml +1 -1
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/db.py +21 -5
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/library.py +21 -14
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_db.py +33 -1
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/uv.lock +1 -1
- transcript_viewer-0.16.0/postgresql:/postgres:x@localhost/postgres +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.coverage +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.github/workflows/publish.yml +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.github/workflows/test.yml +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/.gitignore +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/README.md +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/TODO.md +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Anchor.dc.html +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Dense.dc.html +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Hover.dc.html +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Main.dc.html +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/Quiet.dc.html +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/canvas.json +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/design/delegation-timeline.html +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/__init__.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/ai.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/cache.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/cli.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/config.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/corpus.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/deploy.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/fetch.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/identity.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/ingest.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/page.html +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/store.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/src/transcript_viewer/viewer.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/fixtures/big-command.json +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/page.test.js +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_ai.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_authorisation.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_cache.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_cli.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_config.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_corpus.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_deploy.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_fetch.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_identity.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_library.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_page.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_readme.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_store.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_stream.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_style.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_sync.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_tidiness.py +0 -0
- {transcript_viewer-0.16.0 → transcript_viewer-0.16.1}/tests/test_viewer.py +0 -0
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
256
|
-
" OR EXISTS(SELECT 1 FROM tags WHERE content_hash =
|
|
257
|
-
" OR EXISTS(SELECT 1 FROM marks WHERE content_hash =
|
|
258
|
-
|
|
259
|
-
).fetchone()[
|
|
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 = ?)",
|
|
271
|
-
|
|
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 =
|
|
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
|
+
)
|
|
Binary file
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|