generated from john/python-template
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67feeb28af | ||
|
|
c6ed3126e0 |
@@ -53,14 +53,14 @@ Where **Enforced by** reads *unenforced*, recommending a deterministic test is i
|
||||
| :-- | :--- | :--- |
|
||||
| 1 | **Service boundary rule:** no service-to-service imports | `tests/test_service_boundaries.py` |
|
||||
| 2 | **UI boundary rule:** pages/components do not perform persistence access | `tests/test_ui_boundaries.py` |
|
||||
| 3 | **Status vocabulary conformance:** `JobStatus`/`JobSourceStatus` usage matches current enums in `src/transcription/db/models.py`; no stringly-typed status literals | *unenforced* — only incidental coverage via `tests/services/test_job_service.py` |
|
||||
| 3 | **Status vocabulary conformance:** `JobStatus`/`JobSourceStatus`/`JobPurpose` usage matches current enums in `src/transcription/db/models.py`; no stringly-typed status literals | `tests/test_model_contract_guards.py` |
|
||||
| 4 | **Evidence ownership conformance:** append-only attempt history is preserved and projection writes are not mistaken for history mutation (`src/transcription/services/sources.py`, `src/transcription/services/evidence.py`) | `tests/test_v42_evidence.py::test_attempts_are_append_only_and_exported_with_integrity` |
|
||||
| 5 | **Canonical authority:** findings must resolve against `docs/*` first | `tests/test_meta_contract_guards.py::test_canonical_authority_references_are_present` |
|
||||
| 6 | **Schema contract fidelity:** when model/persistence behavior changes, `docs/schema.md` remains field-accurate with `src/transcription/db/models.py` | *partial* — `tests/test_meta_contract_guards.py` verifies presence and references only, **not** field accuracy |
|
||||
| 6 | **Schema contract fidelity:** when model/persistence behavior changes, `docs/schema.md` remains field-accurate with `src/transcription/db/models.py` | `tests/test_model_contract_guards.py` (field names, ordering, enum members, table coverage), `tests/test_meta_contract_guards.py` (presence and references) |
|
||||
| 7 | **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths | `tests/test_media_path_safety.py`, `tests/ui/test_media_urls.py` |
|
||||
| 8 | **Eager-loading conformance:** service/UI read paths satisfy `lazy="raise"` expectations | *unenforced* — no guard test; only incidental use in `tests/ui/test_sources_page.py` |
|
||||
| 8 | **Eager-loading conformance:** service/UI read paths satisfy `lazy="raise"` expectations | `tests/test_model_contract_guards.py` (declaration-side; documented `noload` exceptions must match `docs/schema.md`) |
|
||||
| 9 | **Cross-cutting error conformance:** service/API/UI translation and retry behavior align with `.github/instructions/error-handling.instructions.md` | `tests/test_errors.py`, `tests/api/test_error_responses.py`, `tests/ui/test_error_presenter.py` |
|
||||
| 10 | **Orphaned/dead-code conformance:** include a deterministic orphan sweep and report confirmed orphans removed/retained with rationale | *unenforced* |
|
||||
| 10 | **Orphaned/dead-code conformance:** include a deterministic orphan sweep and report confirmed orphans removed/retained with rationale | `tests/test_orphan_sweep.py` (`KNOWN_ORPHANS` records each retained orphan and its rationale) |
|
||||
|
||||
## Core Review Areas
|
||||
|
||||
|
||||
+10
-5
@@ -1,18 +1,23 @@
|
||||
# Quality gate for V4.6 [HIGH-06]. Both hooks are blocking: a regression in
|
||||
# `ruff check` or `ty check` fails the commit.
|
||||
# Quality gate for V4.6 [HIGH-06]. `ruff check` is blocking. `ty check` is advisory
|
||||
# during release stabilization: it reports its whole-project baseline without failing
|
||||
# the commit. Restore it to blocking once that baseline is clear.
|
||||
#
|
||||
# Both tools are uv-managed dev dependencies and are not on PATH, so each entry must
|
||||
# go through `uv run`.
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: ruff check
|
||||
entry: ruff check
|
||||
entry: uv run ruff check
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
- id: ty
|
||||
name: ty check
|
||||
entry: ty check
|
||||
name: ty check (advisory)
|
||||
entry: python -c "import subprocess, sys; subprocess.run(['uv', 'run', 'ty', 'check']); sys.exit(0)"
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
verbose: true
|
||||
|
||||
@@ -14,8 +14,8 @@ from fastapi import status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .api.errors import register_error_handlers
|
||||
from .api.documents_api import router as documents_router
|
||||
from .api.errors import register_error_handlers
|
||||
from .api.health import router as health_router
|
||||
from .api.print_api import router as print_router
|
||||
from .config import Settings
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -17,7 +18,6 @@ from sqlalchemy import Table
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
@@ -64,17 +64,15 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
|
||||
payload: dict[str, Any] = {
|
||||
"schema_name": "transcription.export-import",
|
||||
"schema_version": "1",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"tables": {},
|
||||
}
|
||||
|
||||
engine = create_engine(source_db_url)
|
||||
legacy_portrait_rows: list[dict[str, Any]] = []
|
||||
source_has_photo_table = False
|
||||
try:
|
||||
try: # noqa: PLR1702
|
||||
inspector = sqlalchemy_inspect(engine)
|
||||
source_tables = set(inspector.get_table_names())
|
||||
source_has_photo_table = "photo" in source_tables
|
||||
metadata = MetaData()
|
||||
metadata.reflect(bind=engine)
|
||||
current_metadata = SQLModel.metadata
|
||||
@@ -115,7 +113,6 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
|
||||
_prepare_photo_payload_and_uploads(
|
||||
payload=payload,
|
||||
uploads_bundle_dir=uploads_bundle_dir,
|
||||
source_has_photo_table=source_has_photo_table,
|
||||
legacy_portrait_rows=legacy_portrait_rows,
|
||||
)
|
||||
_relocate_homepage_markdown(uploads_bundle_dir=uploads_bundle_dir)
|
||||
@@ -201,7 +198,7 @@ def default_sync_db_url(settings: Settings | None = None) -> str:
|
||||
def _serialize_row(row: dict[str, Any], *, table_name: str, source_upload_dir: Path) -> dict[str, Any]:
|
||||
serialized: dict[str, Any] = {}
|
||||
for key, value in row.items():
|
||||
serialized_value = _serialize_value(key, value)
|
||||
serialized_value = _serialize_value(value)
|
||||
if table_name == "source" and key == "file_path" and isinstance(serialized_value, str):
|
||||
serialized[key] = _canonical_media_relative_path(
|
||||
serialized_value,
|
||||
@@ -237,7 +234,7 @@ def _split_legacy_full_name(full_name: str) -> tuple[str, str]:
|
||||
return ("Unknown", "Unknown")
|
||||
|
||||
|
||||
def _serialize_value(key: str, value: Any) -> Any:
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
@@ -245,9 +242,9 @@ def _serialize_value(key: str, value: Any) -> Any:
|
||||
if isinstance(value, bytes):
|
||||
return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _serialize_value("", v) for k, v in value.items()}
|
||||
return {str(k): _serialize_value(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_serialize_value("", item) for item in value]
|
||||
return [_serialize_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
@@ -314,11 +311,10 @@ def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, prefe
|
||||
return Path(normalized).as_posix()
|
||||
|
||||
|
||||
def _prepare_photo_payload_and_uploads(
|
||||
def _prepare_photo_payload_and_uploads( # noqa: PLR0915
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
uploads_bundle_dir: Path,
|
||||
source_has_photo_table: bool,
|
||||
legacy_portrait_rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
photo_rows = payload.setdefault("tables", {}).setdefault("photo", [])
|
||||
@@ -357,7 +353,7 @@ def _prepare_photo_payload_and_uploads(
|
||||
}
|
||||
has_homepage_primary = any(bool(row.get("is_primary")) for row in existing_homepage_rows)
|
||||
|
||||
now_iso = datetime.now().isoformat()
|
||||
now_iso = datetime.now(UTC).isoformat()
|
||||
for row in legacy_portrait_rows:
|
||||
portrait_path = row.get("portrait_path")
|
||||
person_id = row.get("id")
|
||||
@@ -406,7 +402,8 @@ def _prepare_photo_payload_and_uploads(
|
||||
[
|
||||
path
|
||||
for path in legacy_homepage_dir.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
||||
if path.is_file()
|
||||
and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
||||
],
|
||||
key=lambda path: (path.stat().st_mtime, path.name),
|
||||
)
|
||||
|
||||
@@ -81,7 +81,11 @@ async def reconcile_canonical_media_paths(*, engine: AsyncEngine | None = None)
|
||||
inspector = sqlalchemy_inspect(sync_connection)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "source" in table_names:
|
||||
rows = sync_connection.execute(text('select id, file_path from "source" where file_path is not null')).mappings().all()
|
||||
rows = (
|
||||
sync_connection.execute(text('select id, file_path from "source" where file_path is not null'))
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
original = str(row["file_path"])
|
||||
normalized = _canonical_relative_path(original, preferred_prefix="documents/")
|
||||
@@ -107,7 +111,6 @@ async def reconcile_canonical_media_paths(*, engine: AsyncEngine | None = None)
|
||||
rows_changed += 1
|
||||
return rows_changed
|
||||
|
||||
|
||||
async with active_engine.begin() as connection:
|
||||
rows_changed = await connection.run_sync(_reconcile)
|
||||
if rows_changed:
|
||||
@@ -177,7 +180,7 @@ def _canonical_relative_path(value: str, *, preferred_prefix: str) -> str | None
|
||||
return None
|
||||
|
||||
lowered = normalized.casefold()
|
||||
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
|
||||
if lowered.startswith(("http://", "https://", "data:")):
|
||||
return None
|
||||
|
||||
if lowered.startswith("/uploads/"):
|
||||
|
||||
@@ -12,8 +12,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..db.loading import orm_attribute
|
||||
from ..db.loading import selectinload
|
||||
from ..db.models import ExecutionAttempt
|
||||
from ..db.models import Document
|
||||
from ..db.models import ExecutionAttempt
|
||||
from ..db.models import Job
|
||||
from ..db.models import JobSource
|
||||
from ..db.models import JobSourceStatus
|
||||
|
||||
@@ -21,10 +21,9 @@ from ..db.loading import orm_attribute
|
||||
from ..db.loading import selectinload
|
||||
from ..db.models import Document
|
||||
from ..db.models import DocumentPerson
|
||||
from ..db.models import Photo
|
||||
from ..db.models import Person
|
||||
from ..db.models import PersonTag
|
||||
from ..db.models import PersonRole
|
||||
from ..db.models import PersonTag
|
||||
from ..db.models import Tag
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
@@ -156,7 +155,11 @@ class PeopleService(ServiceBase):
|
||||
existing = await _session.get(
|
||||
Person,
|
||||
person.id,
|
||||
options=(selectinload(Person.document_people), selectinload(Person.person_tags), selectinload(Person.photos)),
|
||||
options=(
|
||||
selectinload(Person.document_people),
|
||||
selectinload(Person.person_tags),
|
||||
selectinload(Person.photos),
|
||||
),
|
||||
)
|
||||
if existing is None:
|
||||
raise self._not_found(f"Person with id {person.id} not found")
|
||||
|
||||
@@ -75,7 +75,8 @@ def _attach_panzoom(*, host_id: str) -> None:
|
||||
const buildInstance = () => {{
|
||||
cleanup();
|
||||
if (media && media.naturalWidth > 0 && media.naturalHeight > 0) {{
|
||||
host.style.setProperty('--panzoom-media-aspect', `${{media.naturalWidth}} / ${{media.naturalHeight}}`);
|
||||
host.style.setProperty(
|
||||
'--panzoom-media-aspect', `${{media.naturalWidth}} / ${{media.naturalHeight}}`);
|
||||
}}
|
||||
const instance = Panzoom(target, {{
|
||||
maxScale: 256,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Presentation-only formatting shared by archival UI surfaces."""
|
||||
|
||||
import re
|
||||
from urllib.parse import quote_plus
|
||||
from datetime import date
|
||||
from urllib.parse import quote_plus
|
||||
from uuid import UUID
|
||||
|
||||
from transcription.db.models import Person
|
||||
|
||||
@@ -28,7 +28,9 @@ def resolve_media_url(path: str | None, *, upload_dir: Path, base_url: str) -> s
|
||||
if lowered.startswith(_ABSOLUTE_SCHEMES):
|
||||
return normalized
|
||||
if normalized.startswith(_UPLOAD_ROUTE_PREFIX):
|
||||
return _resolve_upload_relative(normalized.removeprefix(_UPLOAD_ROUTE_PREFIX), upload_dir=upload_dir, base_url=base_url)
|
||||
return _resolve_upload_relative(
|
||||
normalized.removeprefix(_UPLOAD_ROUTE_PREFIX), upload_dir=upload_dir, base_url=base_url
|
||||
)
|
||||
if lowered.startswith(_CANONICAL_PREFIXES):
|
||||
return _resolve_upload_relative(normalized, upload_dir=upload_dir, base_url=base_url)
|
||||
|
||||
|
||||
@@ -51,8 +51,7 @@ def _render_homepage_gallery(
|
||||
|
||||
if active_index[0] >= len(photos):
|
||||
active_index[0] = len(photos) - 1
|
||||
if active_index[0] < 0:
|
||||
active_index[0] = 0
|
||||
active_index[0] = max(active_index[0], 0)
|
||||
|
||||
current_photo = photos[active_index[0]]
|
||||
dark_room_viewer(
|
||||
@@ -137,7 +136,7 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
|
||||
ui.element("div")
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
def register_page() -> None: # noqa: PLR0915
|
||||
"""Register the homepage routes."""
|
||||
|
||||
@ui.page("/homepage", title="VibeScribe Home")
|
||||
@@ -173,7 +172,7 @@ def register_page() -> None:
|
||||
)
|
||||
|
||||
@ui.page("/homepage/edit", title="Edit Homepage")
|
||||
async def homepage_edit_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
async def homepage_edit_page(request: Request, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
||||
photos_service = PhotosService(session_factory=session_factory)
|
||||
settings = resolve_runtime_settings(request)
|
||||
render_navigation_header(current_path="/homepage")
|
||||
|
||||
@@ -148,7 +148,9 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||
provider_options = [settings.provider.value]
|
||||
model_options = list(settings.provider_models) or ([settings.provider_model] if settings.provider_model else [])
|
||||
model_options = list(settings.provider_models) or (
|
||||
[settings.provider_model] if settings.provider_model else []
|
||||
)
|
||||
if locked_source is not None:
|
||||
provider_input = (
|
||||
ui.select(provider_options, label="Provider", value=settings.provider.value)
|
||||
|
||||
@@ -12,8 +12,8 @@ from nicegui import ui
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.db.models import Person
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.people import PeopleError
|
||||
from transcription.services.people import PeopleService
|
||||
from transcription.services.photos import PhotoError
|
||||
@@ -115,7 +115,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
render_people_table(rows)
|
||||
|
||||
@ui.page("/people/new")
|
||||
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
async def person_create_page(session_factory: SessionFactoryDep) -> None:
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
@@ -230,7 +230,9 @@ def register_page() -> None: # noqa: PLR0915
|
||||
_render_person_biography_zone(person)
|
||||
|
||||
@ui.page("/people/{person_id}/photos")
|
||||
async def person_photos_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
async def person_photos_page( # noqa: PLR0915
|
||||
person_id: str, request: Request, session_factory: SessionFactoryDep
|
||||
) -> None:
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
photos_service = PhotosService(session_factory=session_factory)
|
||||
settings = resolve_runtime_settings(request)
|
||||
@@ -267,7 +269,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
|
||||
@ui.refreshable
|
||||
def render_gallery() -> None:
|
||||
with archival_card(title="Photo Gallery", extra_classes="gap-3"):
|
||||
with archival_card(title="Photo Gallery", extra_classes="gap-3"): # noqa: PLR1702
|
||||
if not photos:
|
||||
render_empty_state("No portrait photo uploaded yet.")
|
||||
return
|
||||
@@ -284,11 +286,13 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.image(photo_url).classes("w-full rounded-md")
|
||||
description_text = photo.description or "No description"
|
||||
ui.label(description_text).classes(
|
||||
"absolute inset-x-0 bottom-0 text-center text-white text-xs font-semibold px-2 py-1 bg-black/60 rounded-b-md"
|
||||
"absolute inset-x-0 bottom-0 text-center text-white text-xs font-semibold "
|
||||
"px-2 py-1 bg-black/60 rounded-b-md"
|
||||
)
|
||||
if photo.is_primary:
|
||||
ui.label("Primary").classes(
|
||||
"absolute top-2 right-2 text-[11px] text-white font-semibold px-2 py-1 bg-primary/80 rounded"
|
||||
"absolute top-2 right-2 text-[11px] text-white font-semibold "
|
||||
"px-2 py-1 bg-primary/80 rounded"
|
||||
)
|
||||
|
||||
description_input = (
|
||||
@@ -335,18 +339,24 @@ def register_page() -> None: # noqa: PLR0915
|
||||
ui.navigate.to(f"/people/{person.id}/photos")
|
||||
|
||||
with ui.row().classes("w-full items-center gap-1"):
|
||||
ui.button("Save Description", on_click=save_description, icon="save").props("flat dense")
|
||||
ui.button("Save Description", on_click=save_description, icon="save").props(
|
||||
"flat dense"
|
||||
)
|
||||
if not photo.is_primary:
|
||||
ui.button("Set Primary", on_click=set_primary, icon="star").props("flat dense")
|
||||
ui.button("Delete Photo", on_click=delete_photo, icon="delete").props("flat dense color=negative")
|
||||
ui.button("Delete Photo", on_click=delete_photo, icon="delete").props(
|
||||
"flat dense color=negative"
|
||||
)
|
||||
|
||||
with ui.column().classes("w-full max-w-[1400px] mx-auto p-4 gap-4"):
|
||||
with section_header_row():
|
||||
page_header("Edit Photos", subtitle=f"{person.full_name} ({person.id})")
|
||||
with ui.row().classes("items-center gap-2"):
|
||||
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
||||
"flat"
|
||||
)
|
||||
ui.button(
|
||||
"Back to Person",
|
||||
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
||||
icon="arrow_back",
|
||||
).props("flat")
|
||||
ui.upload(
|
||||
label="",
|
||||
on_upload=on_photo_selected,
|
||||
@@ -364,7 +374,7 @@ def register_page() -> None: # noqa: PLR0915
|
||||
render_gallery()
|
||||
|
||||
@ui.page("/people/{person_id}/edit")
|
||||
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||
async def person_edit_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||
people_service = PeopleService(session_factory=session_factory)
|
||||
document_service = DocumentService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/people")
|
||||
@@ -623,15 +633,15 @@ async def _render_person_photo_zone(
|
||||
photos = await photos_service.list_photos(person_id=person.id)
|
||||
active_index = [0]
|
||||
|
||||
with ui.column().classes("col-span-12 lg:col-span-4"):
|
||||
with archival_card(title="Photos", extra_classes="gap-3"):
|
||||
_render_photo_viewer_with_navigation(
|
||||
photos=photos,
|
||||
active_index=active_index,
|
||||
settings=settings,
|
||||
request=request,
|
||||
empty_message="No portrait photo uploaded yet.",
|
||||
)
|
||||
with ui.column().classes("col-span-12 lg:col-span-4"), archival_card(title="Photos", extra_classes="gap-3"):
|
||||
_render_photo_viewer_with_navigation(
|
||||
photos=photos,
|
||||
active_index=active_index,
|
||||
settings=settings,
|
||||
request=request,
|
||||
empty_message="No portrait photo uploaded yet.",
|
||||
)
|
||||
|
||||
|
||||
def _shift_gallery_index(*, photos: list, active_index: list[int], step: int) -> None:
|
||||
if len(photos) < 2:
|
||||
@@ -655,8 +665,7 @@ def _render_photo_viewer_with_navigation(
|
||||
|
||||
if active_index[0] >= len(photos):
|
||||
active_index[0] = len(photos) - 1
|
||||
if active_index[0] < 0:
|
||||
active_index[0] = 0
|
||||
active_index[0] = max(active_index[0], 0)
|
||||
|
||||
current_photo = photos[active_index[0]]
|
||||
photo_url = resolve_media_url(
|
||||
|
||||
@@ -398,7 +398,11 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
||||
)
|
||||
if not summaries_outcome.ok:
|
||||
return
|
||||
summaries = tuple(summary for summary in (summaries_outcome.value or ()) if summary.name == "transcribe_document.md")
|
||||
summaries = tuple(
|
||||
summary
|
||||
for summary in (summaries_outcome.value or ())
|
||||
if summary.name == "transcribe_document.md"
|
||||
)
|
||||
|
||||
if not summaries:
|
||||
render_empty_state("No editable Markdown prompts were found.")
|
||||
|
||||
@@ -23,6 +23,7 @@ from transcription.services.errors import TranscriptionNotFoundError
|
||||
from transcription.services.evidence import EvidenceService
|
||||
from transcription.services.evidence import LatestExecutionAttempt
|
||||
from transcription.services.source_media import lookup_source_mime_type
|
||||
from transcription.services.sources import SourceNavigation
|
||||
from transcription.services.sources import SourceService
|
||||
from transcription.ui.components.app_shell import render_navigation_header
|
||||
from transcription.ui.components.cards import archival_card
|
||||
@@ -652,7 +653,7 @@ def _render_machine_candidates(
|
||||
successful = [
|
||||
attempt
|
||||
for attempt in attempts
|
||||
if attempt.status.value == "transcribed" and attempt.raw_transcription
|
||||
if attempt.status == JobSourceStatus.TRANSCRIBED and attempt.raw_transcription
|
||||
]
|
||||
candidates = [
|
||||
attempt for attempt in successful if attempt.id != source.preferred_execution_attempt_id
|
||||
|
||||
@@ -12,10 +12,10 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.api.documents_api import get_document_service
|
||||
from transcription.api.documents_api import get_people_service
|
||||
from transcription.api.documents_api import router
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.config import Settings
|
||||
from transcription.config import SqliteSettings
|
||||
from transcription.db import create_all
|
||||
|
||||
@@ -14,9 +14,9 @@ from transcription.db.models import DocumentTag
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonTag
|
||||
from transcription.db.models import Photo
|
||||
from transcription.db.models import Source
|
||||
from transcription.db.models import Tag
|
||||
from transcription.db.models import Photo
|
||||
from transcription.services.documents import DocumentDeleteBlockedError
|
||||
from transcription.services.documents import DocumentError
|
||||
from transcription.services.documents import DocumentService
|
||||
|
||||
@@ -61,15 +61,21 @@ async def test_people_service_handles_person_and_document_person_crud(default_se
|
||||
async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory):
|
||||
people_service = PeopleService(session_factory=default_session_factory)
|
||||
|
||||
created = await people_service.create_person(Person(given_names="Hig", last_name="Higgins", family_search_id=" g8t4-mdq "))
|
||||
created = await people_service.create_person(
|
||||
Person(given_names="Hig", last_name="Higgins", family_search_id=" g8t4-mdq ")
|
||||
)
|
||||
assert created.family_search_id == "G8T4-MDQ"
|
||||
|
||||
with pytest.raises(PeopleError) as duplicate:
|
||||
await people_service.create_person(Person(given_names="Duplicate", last_name="Hig", family_search_id="G8T4-MDQ"))
|
||||
await people_service.create_person(
|
||||
Person(given_names="Duplicate", last_name="Hig", family_search_id="G8T4-MDQ")
|
||||
)
|
||||
assert duplicate.value.category == ErrorCategory.CONFLICT
|
||||
|
||||
with pytest.raises(PeopleError) as malformed:
|
||||
await people_service.create_person(Person(given_names="Malformed", last_name="Person", family_search_id="not-an-id"))
|
||||
await people_service.create_person(
|
||||
Person(given_names="Malformed", last_name="Person", family_search_id="not-an-id")
|
||||
)
|
||||
assert malformed.value.category == ErrorCategory.VALIDATION
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Deterministic guards for the model and persistence contracts.
|
||||
|
||||
These cover three checks that `.github/skills/python-code-reviewer/skill.md` requires
|
||||
on every review but that previously had no automated enforcement:
|
||||
|
||||
* **Status vocabulary conformance** - status comparisons and assignments must use the
|
||||
`JobStatus` / `JobSourceStatus` / `JobPurpose` enums rather than string literals.
|
||||
* **Relationship loading contract** - relationships declare ``lazy="raise"`` so read
|
||||
paths must eager-load explicitly, and the documented exceptions in `docs/schema.md`
|
||||
must match the code exactly.
|
||||
* **Schema contract fidelity** - the "Field-Accurate Table Contracts" tables in
|
||||
`docs/schema.md` must list exactly the fields each SQLModel table declares.
|
||||
|
||||
See `docs/requirements.md` (REQ-4-102), `docs/schema.md` ("Relationship Loading
|
||||
Contract"), and `.github/instructions/services.instructions.md`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from transcription.db import models as models_module
|
||||
from transcription.db.models import JobPurpose
|
||||
from transcription.db.models import JobSourceStatus
|
||||
from transcription.db.models import JobStatus
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_DIR = PROJECT_ROOT / "src" / "transcription"
|
||||
MODELS_PATH = SOURCE_DIR / "db" / "models.py"
|
||||
SCHEMA_DOC = PROJECT_ROOT / "docs" / "schema.md"
|
||||
|
||||
STATUS_ENUMS = (JobStatus, JobSourceStatus, JobPurpose)
|
||||
STATUS_VALUES = frozenset(member.value for enum in STATUS_ENUMS for member in enum)
|
||||
|
||||
# Attribute names that carry a status enum. A string literal compared against or
|
||||
# assigned to one of these is a stringly-typed status, even if it happens to match.
|
||||
STATUS_ATTRIBUTES = frozenset({"status", "purpose"})
|
||||
|
||||
# `JobSource.execution_attempts` loads attempt evidence on demand rather than raising,
|
||||
# because evidence is fetched deliberately by the services that own it. Documented in
|
||||
# `docs/schema.md` under "Relationship Loading Contract".
|
||||
DOCUMENTED_LOADING_EXCEPTIONS = {"execution_attempts": "noload"}
|
||||
|
||||
|
||||
def _python_files() -> list[Path]:
|
||||
return sorted(SOURCE_DIR.rglob("*.py"))
|
||||
|
||||
|
||||
def _relative(path: Path) -> str:
|
||||
return path.relative_to(PROJECT_ROOT).as_posix()
|
||||
|
||||
|
||||
def _is_status_target(node: ast.expr) -> bool:
|
||||
"""True for `x.status`, `x.purpose`, and their `.value` unwrappings."""
|
||||
if isinstance(node, ast.Attribute):
|
||||
if node.attr in STATUS_ATTRIBUTES:
|
||||
return True
|
||||
if node.attr == "value":
|
||||
return _is_status_target(node.value)
|
||||
return False
|
||||
|
||||
|
||||
def _string_constant(node: ast.expr) -> str | None:
|
||||
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
|
||||
|
||||
|
||||
def _status_literal_violations(tree: ast.Module) -> list[tuple[int, str]]:
|
||||
found: list[tuple[int, str]] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Compare):
|
||||
operands = [node.left, *node.comparators]
|
||||
if not any(_is_status_target(operand) for operand in operands):
|
||||
continue
|
||||
for operand in operands:
|
||||
literal = _string_constant(operand)
|
||||
if literal is not None:
|
||||
found.append((node.lineno, literal))
|
||||
elif isinstance(node, ast.Call):
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg not in STATUS_ATTRIBUTES:
|
||||
continue
|
||||
literal = _string_constant(keyword.value)
|
||||
if literal is not None:
|
||||
found.append((node.lineno, literal))
|
||||
return found
|
||||
|
||||
|
||||
def test_status_enums_expose_expected_vocabulary():
|
||||
"""Guard the guard: the scan below is meaningless if the enums are empty."""
|
||||
assert {member.value for member in JobStatus} == {
|
||||
"queued",
|
||||
"processing",
|
||||
"transcribed",
|
||||
"partial_success",
|
||||
"failed",
|
||||
}
|
||||
assert {member.value for member in JobSourceStatus} == {
|
||||
"pending",
|
||||
"transcribed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
}
|
||||
assert {member.value for member in JobPurpose} == {"transcription", "retranscription"}
|
||||
|
||||
|
||||
def test_no_stringly_typed_status_comparisons_or_assignments():
|
||||
"""Status handling must go through the enums, never raw strings.
|
||||
|
||||
`attempt.status.value == "transcribed"` silently survives an enum rename and
|
||||
compares a projection of the value rather than the value itself.
|
||||
"""
|
||||
violations: dict[str, list[tuple[int, str]]] = {}
|
||||
for path in _python_files():
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
found = _status_literal_violations(tree)
|
||||
if found:
|
||||
violations[_relative(path)] = found
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_status_string_literals_outside_models_are_accounted_for():
|
||||
"""Any bare status-valued literal in the package must be a known non-status use.
|
||||
|
||||
This is deliberately narrower than the comparison scan: it catches literals that
|
||||
merely *look* like statuses, so a genuine new one cannot slip in unnoticed.
|
||||
"""
|
||||
allowed = {
|
||||
# `JobStatus` / `JobSourceStatus` / `JobPurpose` member definitions.
|
||||
"src/transcription/db/models.py",
|
||||
# `WorkerHealthState` is a separate Literal vocabulary that reuses "failed".
|
||||
"src/transcription/worker.py",
|
||||
# Distribution name lookups for `transcription`, not the JobPurpose member.
|
||||
"src/transcription/config.py",
|
||||
"src/transcription/providers/evidence.py",
|
||||
# UI placeholder copy where a value is absent, not a status render.
|
||||
"src/transcription/ui/pages/jobs_page.py",
|
||||
}
|
||||
unexpected: dict[str, list[tuple[int, str]]] = {}
|
||||
for path in _python_files():
|
||||
relative = _relative(path)
|
||||
if relative in allowed:
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
found = [
|
||||
(node.lineno, node.value)
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value in STATUS_VALUES
|
||||
]
|
||||
if found:
|
||||
unexpected[relative] = found
|
||||
assert unexpected == {}
|
||||
|
||||
|
||||
def _relationship_loading_strategies() -> dict[str, dict[str, str | None]]:
|
||||
"""Map each model attribute defined via `Relationship(...)` to its lazy strategy."""
|
||||
tree = ast.parse(MODELS_PATH.read_text(encoding="utf-8"))
|
||||
strategies: dict[str, dict[str, str | None]] = {}
|
||||
for class_node in tree.body:
|
||||
if not isinstance(class_node, ast.ClassDef):
|
||||
continue
|
||||
for statement in class_node.body:
|
||||
if not isinstance(statement, ast.AnnAssign) or statement.value is None:
|
||||
continue
|
||||
call = statement.value
|
||||
if not isinstance(call, ast.Call) or getattr(call.func, "id", None) != "Relationship":
|
||||
continue
|
||||
attribute = statement.target.id if isinstance(statement.target, ast.Name) else "<unknown>"
|
||||
lazy: str | None = None
|
||||
for keyword in call.keywords:
|
||||
if keyword.arg != "sa_relationship_kwargs" or not isinstance(keyword.value, ast.Dict):
|
||||
continue
|
||||
for key, value in zip(keyword.value.keys, keyword.value.values, strict=True):
|
||||
if isinstance(key, ast.Constant) and key.value == "lazy":
|
||||
lazy = _string_constant(value)
|
||||
strategies.setdefault(class_node.name, {})[attribute] = lazy
|
||||
return strategies
|
||||
|
||||
|
||||
def test_relationships_are_discovered():
|
||||
"""Guard the guard: the loading rules below are meaningless if nothing is scanned."""
|
||||
strategies = _relationship_loading_strategies()
|
||||
assert {"Document", "Job", "JobSource", "Source"} <= set(strategies)
|
||||
assert sum(len(attributes) for attributes in strategies.values()) >= 25
|
||||
|
||||
|
||||
def test_relationships_declare_lazy_raise_except_documented_cases():
|
||||
"""REQ-4-102: relationships raise on implicit load so read shape stays explicit."""
|
||||
violations: dict[str, str | None] = {}
|
||||
for model_name, attributes in _relationship_loading_strategies().items():
|
||||
for attribute, lazy in attributes.items():
|
||||
expected = DOCUMENTED_LOADING_EXCEPTIONS.get(attribute, "raise")
|
||||
if lazy != expected:
|
||||
violations[f"{model_name}.{attribute}"] = lazy
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_documented_loading_exceptions_match_schema_doc():
|
||||
"""The exception list is only trustworthy while `docs/schema.md` agrees with it."""
|
||||
schema_text = SCHEMA_DOC.read_text(encoding="utf-8")
|
||||
contract = schema_text.split("## Relationship Loading Contract", 1)[1]
|
||||
for attribute, strategy in DOCUMENTED_LOADING_EXCEPTIONS.items():
|
||||
assert attribute in contract, f"{attribute} is exempted in code but not documented"
|
||||
assert f'`lazy="{strategy}"`' in contract
|
||||
|
||||
|
||||
def _documented_table_fields() -> dict[str, list[str]]:
|
||||
schema_text = SCHEMA_DOC.read_text(encoding="utf-8")
|
||||
section = schema_text.split("## Field-Accurate Table Contracts", 1)[1]
|
||||
documented: dict[str, list[str]] = {}
|
||||
for block in re.split(r"\n### ", section)[1:]:
|
||||
heading = block.splitlines()[0].strip().strip("`")
|
||||
documented[heading] = re.findall(r"^\| `([^`]+)` \|", block, re.MULTILINE)
|
||||
return documented
|
||||
|
||||
|
||||
def _table_models() -> dict[str, type[SQLModel]]:
|
||||
return {
|
||||
name: attribute
|
||||
for name, attribute in vars(models_module).items()
|
||||
if isinstance(attribute, type)
|
||||
and issubclass(attribute, SQLModel)
|
||||
and attribute is not SQLModel
|
||||
and getattr(attribute, "__table__", None) is not None
|
||||
}
|
||||
|
||||
|
||||
def test_schema_doc_documents_every_table_model():
|
||||
"""Every persisted table needs a field contract, and vice versa."""
|
||||
documented = set(_documented_table_fields())
|
||||
actual = set(_table_models())
|
||||
assert actual, "no table models discovered"
|
||||
assert documented - actual == set(), "schema.md documents tables that no longer exist"
|
||||
assert actual - documented == set(), "schema.md is missing tables that exist in models.py"
|
||||
|
||||
|
||||
def test_schema_doc_field_contracts_match_models():
|
||||
"""Check 6: `docs/schema.md` stays field-accurate with `db/models.py`."""
|
||||
documented = _documented_table_fields()
|
||||
drift: dict[str, dict[str, list[str]]] = {}
|
||||
for name, model in _table_models().items():
|
||||
expected = set(model.model_fields)
|
||||
listed = set(documented.get(name, []))
|
||||
if expected != listed:
|
||||
drift[name] = {
|
||||
"undocumented_fields": sorted(expected - listed),
|
||||
"stale_doc_entries": sorted(listed - expected),
|
||||
}
|
||||
assert drift == {}
|
||||
|
||||
|
||||
def test_schema_doc_lists_fields_in_declaration_order():
|
||||
"""Ordering drift is how a doc silently stops being reviewable against the model."""
|
||||
documented = _documented_table_fields()
|
||||
misordered = {
|
||||
name: {"documented": documented[name], "declared": list(model.model_fields)}
|
||||
for name, model in _table_models().items()
|
||||
if documented.get(name, []) != list(model.model_fields)
|
||||
}
|
||||
assert misordered == {}
|
||||
|
||||
|
||||
def test_schema_doc_enumerations_match_status_enums():
|
||||
"""The "Authoritative Enumerations" section must list the real members."""
|
||||
schema_text = SCHEMA_DOC.read_text(encoding="utf-8")
|
||||
section = schema_text.split("## Authoritative Enumerations", 1)[1].split("\n## ", 1)[0]
|
||||
drift: dict[str, dict[str, list[str]]] = {}
|
||||
for enum in STATUS_ENUMS:
|
||||
block = re.split(rf"\n### {enum.__name__}\n", section)
|
||||
assert len(block) == 2, f"{enum.__name__} has no section in docs/schema.md"
|
||||
listed = re.findall(r"^- `([^`]+)`", block[1].split("\n### ", 1)[0], re.MULTILINE)
|
||||
expected = [member.value for member in enum]
|
||||
if listed != expected:
|
||||
drift[enum.__name__] = {"documented": listed, "declared": expected}
|
||||
assert drift == {}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Deterministic orphan sweep for the `transcription` package.
|
||||
|
||||
`.github/skills/python-code-reviewer/skill.md` requires every review to report
|
||||
orphaned code as removed, retained-with-justification, or uncertain-follow-up. This
|
||||
guard makes that sweep reproducible: it locks the current set of unreferenced public
|
||||
definitions, so a newly stranded function fails the build instead of accumulating
|
||||
silently, and deleting a known orphan requires deleting its entry here.
|
||||
|
||||
The sweep is intentionally conservative. It only considers module-level public
|
||||
definitions, and it honours the dynamic-wiring exceptions the skill calls out:
|
||||
framework route registration, string-based entrypoint references, and use from
|
||||
`tests/` or `tools/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_DIR = PROJECT_ROOT / "src" / "transcription"
|
||||
|
||||
# Every tree that may legitimately consume package API.
|
||||
REFERENCE_ROOTS = (SOURCE_DIR, PROJECT_ROOT / "tests", PROJECT_ROOT / "tools")
|
||||
|
||||
# Decorators that hand a callable to a framework registry, making the definition
|
||||
# reachable without any in-repo reference to its name.
|
||||
REGISTRATION_DECORATOR_PREFIXES = ("router.", "app.", "ui.page")
|
||||
|
||||
# Confirmed orphans, retained by decision rather than by reference. Each entry needs a
|
||||
# rationale. Removing the code means removing the entry; adding an entry means an
|
||||
# explicit decision to keep unreferenced code.
|
||||
KNOWN_ORPHANS: dict[str, str] = {
|
||||
"BenchmarkManifest": (
|
||||
"Benchmark manifest model in benchmarking.py with no current caller. "
|
||||
"Uncertain - follow-up: confirm whether the benchmarking entrypoint is still "
|
||||
"intended before removing."
|
||||
),
|
||||
"dispose_all_engines": (
|
||||
"Engine lifecycle helper in db/engine.py. Uncertain - follow-up: operational "
|
||||
"teardown utility with no runtime or test caller."
|
||||
),
|
||||
"refresh_engine": (
|
||||
"Engine lifecycle helper in db/engine.py. Uncertain - follow-up: paired with "
|
||||
"dispose_all_engines and equally unreferenced."
|
||||
),
|
||||
"summarize_error": (
|
||||
"Error-presentation helper in ui/components/error_presenter.py that no page or "
|
||||
"component calls. Uncertain - follow-up: superseded by the presenter's other "
|
||||
"entrypoints."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _source_files() -> list[Path]:
|
||||
return sorted(SOURCE_DIR.rglob("*.py"))
|
||||
|
||||
|
||||
def _is_registered_with_framework(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> bool:
|
||||
return any(
|
||||
ast.unparse(decorator).startswith(REGISTRATION_DECORATOR_PREFIXES) for decorator in node.decorator_list
|
||||
)
|
||||
|
||||
|
||||
def _public_definitions() -> dict[str, str]:
|
||||
"""Public module-level definitions, mapped to `path:line`."""
|
||||
definitions: dict[str, str] = {}
|
||||
for path in _source_files():
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
||||
continue
|
||||
if node.name.startswith("_") or _is_registered_with_framework(node):
|
||||
continue
|
||||
definitions[node.name] = f"{path.relative_to(PROJECT_ROOT).as_posix()}:{node.lineno}"
|
||||
return definitions
|
||||
|
||||
|
||||
def _referenced_names() -> tuple[set[str], str]:
|
||||
"""Names referenced anywhere, plus every string literal joined for dotted lookups."""
|
||||
names: set[str] = set()
|
||||
literals: list[str] = []
|
||||
for root in REFERENCE_ROOTS:
|
||||
for path in sorted(root.rglob("*.py")):
|
||||
# This module names every known orphan in `KNOWN_ORPHANS`; counting those
|
||||
# strings as references would make the allowlist self-satisfying.
|
||||
if path == Path(__file__).resolve():
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Name):
|
||||
names.add(node.id)
|
||||
elif isinstance(node, ast.Attribute):
|
||||
names.add(node.attr)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
for alias in node.names:
|
||||
names.add(alias.name)
|
||||
names.add(alias.asname or alias.name)
|
||||
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
literals.append(node.value)
|
||||
return names, "\n".join(literals)
|
||||
|
||||
|
||||
def _orphans() -> dict[str, str]:
|
||||
definitions = _public_definitions()
|
||||
names, literal_blob = _referenced_names()
|
||||
return {
|
||||
name: location
|
||||
for name, location in definitions.items()
|
||||
# A definition is referenced if its name is used directly, or appears inside a
|
||||
# string such as "transcription.__main__:create_cli_app".
|
||||
if name not in names and name not in literal_blob
|
||||
}
|
||||
|
||||
|
||||
def test_public_definitions_are_discovered():
|
||||
"""Guard the guard: the sweep is meaningless if nothing is scanned."""
|
||||
definitions = _public_definitions()
|
||||
assert len(definitions) >= 200
|
||||
assert "create_app" in definitions
|
||||
|
||||
|
||||
def test_framework_registered_routes_are_exempt():
|
||||
"""Route handlers are reachable via decorator registration, not by name."""
|
||||
definitions = _public_definitions()
|
||||
assert "healthz_route" not in definitions
|
||||
assert "read_document_source_media" not in definitions
|
||||
|
||||
|
||||
def test_no_unexpected_orphaned_definitions():
|
||||
"""Check 10: no public definition becomes unreferenced without a recorded decision."""
|
||||
unexpected = {name: location for name, location in _orphans().items() if name not in KNOWN_ORPHANS}
|
||||
assert unexpected == {}
|
||||
|
||||
|
||||
def test_known_orphans_are_still_orphaned():
|
||||
"""Keep the allowlist honest: an entry that regained callers must be removed."""
|
||||
current = set(_orphans())
|
||||
stale = sorted(name for name in KNOWN_ORPHANS if name not in current)
|
||||
assert stale == [], "these definitions are referenced again; drop them from KNOWN_ORPHANS"
|
||||
|
||||
|
||||
def test_known_orphans_document_a_rationale():
|
||||
"""An allowlist without reasons is just suppressed output."""
|
||||
missing = sorted(name for name, reason in KNOWN_ORPHANS.items() if len(reason.strip()) < 40)
|
||||
assert missing == []
|
||||
@@ -2,23 +2,21 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
# Register table metadata.
|
||||
from transcription.db import models as _models # noqa: F401
|
||||
from transcription.db.migration import MigrationPaths
|
||||
from transcription.db.migration import export_bundle
|
||||
from transcription.db.migration import import_bundle
|
||||
from transcription.db.migration import migrate_via_bundle
|
||||
from transcription.db.migration import sqlite_url_from_path
|
||||
|
||||
# Register table metadata.
|
||||
from transcription.db import models as _models # noqa: F401
|
||||
|
||||
|
||||
def test_export_import_migration_round_trips_db_and_uploads(tmp_path):
|
||||
source_db_path = tmp_path / "source.db"
|
||||
@@ -173,7 +171,10 @@ def test_export_import_migration_backfills_legacy_portraits_and_homepage_images(
|
||||
{"id": person_id},
|
||||
).one()
|
||||
photos = connection.execute(
|
||||
text('select person_id, path, is_primary from "photo" order by person_id is not null desc, created_at asc')
|
||||
text(
|
||||
'select person_id, path, is_primary from "photo" '
|
||||
"order by person_id is not null desc, created_at asc"
|
||||
)
|
||||
).all()
|
||||
assert person_name[0] == "Legacy"
|
||||
assert person_name[1] == "Portrait"
|
||||
@@ -291,7 +292,9 @@ def test_migration_backfills_legacy_media_when_photo_table_contains_stale_rows(t
|
||||
target_engine = create_engine(target_db_url)
|
||||
try:
|
||||
with target_engine.connect() as connection:
|
||||
photos = connection.execute(text('select person_id, path from "photo" order by person_id is null, path')).all()
|
||||
photos = connection.execute(
|
||||
text('select person_id, path from "photo" order by person_id is null, path')
|
||||
).all()
|
||||
# stale row must not survive; legacy portrait + homepage should be backfilled
|
||||
assert len(photos) == 2
|
||||
assert any(row[0] is not None for row in photos)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the documents page routes and action handlers."""
|
||||
|
||||
from datetime import date
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -10,13 +10,13 @@ from sqlmodel import select
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentTag
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.db.models import Tag
|
||||
from transcription.db.models import DocumentTag
|
||||
from transcription.db.models import Source
|
||||
from transcription.db.models import Tag
|
||||
from transcription.ui.pages.documents_page import _resolve_selected_tag_labels
|
||||
|
||||
# --- Helper Fixtures ---
|
||||
|
||||
@@ -11,8 +11,8 @@ from transcription.db import session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonTag
|
||||
from transcription.db.models import PersonRole
|
||||
from transcription.db.models import PersonTag
|
||||
from transcription.db.models import Photo
|
||||
from transcription.db.models import Source
|
||||
from transcription.db.models import Tag
|
||||
|
||||
@@ -6,10 +6,12 @@ Usage examples:
|
||||
uv run python tools/export_import_migration.py export --bundle-dir .migration-bundle
|
||||
|
||||
2) Import bundle into a fresh target DB + uploads root:
|
||||
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db .\\data\\transcription-new.db --target-upload-dir .\\data-new
|
||||
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle
|
||||
--target-db .\\data\\transcription-new.db --target-upload-dir .\\data-new
|
||||
|
||||
3) One-shot rebuild flow:
|
||||
uv run python tools/export_import_migration.py migrate --bundle-dir .migration-bundle --target-db .\\data\\transcription-new.db --target-upload-dir .\\data-new
|
||||
uv run python tools/export_import_migration.py migrate --bundle-dir .migration-bundle
|
||||
--target-db .\\data\\transcription-new.db --target-upload-dir .\\data-new
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
Reference in New Issue
Block a user