generated from john/python-template
Compare commits
2
Commits
5566f48fc0
...
67feeb28af
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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
|
## Core Review Areas
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -1,18 +1,23 @@
|
|||||||
# Quality gate for V4.6 [HIGH-06]. Both hooks are blocking: a regression in
|
# Quality gate for V4.6 [HIGH-06]. `ruff check` is blocking. `ty check` is advisory
|
||||||
# `ruff check` or `ty check` fails the commit.
|
# 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:
|
repos:
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
name: ruff check
|
name: ruff check
|
||||||
entry: ruff check
|
entry: uv run ruff check
|
||||||
language: system
|
language: system
|
||||||
types_or: [python, pyi]
|
types_or: [python, pyi]
|
||||||
require_serial: true
|
require_serial: true
|
||||||
- id: ty
|
- id: ty
|
||||||
name: ty check
|
name: ty check (advisory)
|
||||||
entry: ty check
|
entry: python -c "import subprocess, sys; subprocess.run(['uv', 'run', 'ty', 'check']); sys.exit(0)"
|
||||||
language: system
|
language: system
|
||||||
types_or: [python, pyi]
|
types_or: [python, pyi]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
require_serial: true
|
require_serial: true
|
||||||
|
verbose: true
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from fastapi import status
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
|
||||||
from .api.documents_api import router as documents_router
|
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.health import router as health_router
|
||||||
from .api.print_api import router as print_router
|
from .api.print_api import router as print_router
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,7 +18,6 @@ from sqlalchemy import Table
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.engine import Engine
|
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlmodel import SQLModel
|
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] = {
|
payload: dict[str, Any] = {
|
||||||
"schema_name": "transcription.export-import",
|
"schema_name": "transcription.export-import",
|
||||||
"schema_version": "1",
|
"schema_version": "1",
|
||||||
"created_at": datetime.now().isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"tables": {},
|
"tables": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
engine = create_engine(source_db_url)
|
engine = create_engine(source_db_url)
|
||||||
legacy_portrait_rows: list[dict[str, Any]] = []
|
legacy_portrait_rows: list[dict[str, Any]] = []
|
||||||
source_has_photo_table = False
|
try: # noqa: PLR1702
|
||||||
try:
|
|
||||||
inspector = sqlalchemy_inspect(engine)
|
inspector = sqlalchemy_inspect(engine)
|
||||||
source_tables = set(inspector.get_table_names())
|
source_tables = set(inspector.get_table_names())
|
||||||
source_has_photo_table = "photo" in source_tables
|
|
||||||
metadata = MetaData()
|
metadata = MetaData()
|
||||||
metadata.reflect(bind=engine)
|
metadata.reflect(bind=engine)
|
||||||
current_metadata = SQLModel.metadata
|
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(
|
_prepare_photo_payload_and_uploads(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
uploads_bundle_dir=uploads_bundle_dir,
|
uploads_bundle_dir=uploads_bundle_dir,
|
||||||
source_has_photo_table=source_has_photo_table,
|
|
||||||
legacy_portrait_rows=legacy_portrait_rows,
|
legacy_portrait_rows=legacy_portrait_rows,
|
||||||
)
|
)
|
||||||
_relocate_homepage_markdown(uploads_bundle_dir=uploads_bundle_dir)
|
_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]:
|
def _serialize_row(row: dict[str, Any], *, table_name: str, source_upload_dir: Path) -> dict[str, Any]:
|
||||||
serialized: dict[str, Any] = {}
|
serialized: dict[str, Any] = {}
|
||||||
for key, value in row.items():
|
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):
|
if table_name == "source" and key == "file_path" and isinstance(serialized_value, str):
|
||||||
serialized[key] = _canonical_media_relative_path(
|
serialized[key] = _canonical_media_relative_path(
|
||||||
serialized_value,
|
serialized_value,
|
||||||
@@ -237,7 +234,7 @@ def _split_legacy_full_name(full_name: str) -> tuple[str, str]:
|
|||||||
return ("Unknown", "Unknown")
|
return ("Unknown", "Unknown")
|
||||||
|
|
||||||
|
|
||||||
def _serialize_value(key: str, value: Any) -> Any:
|
def _serialize_value(value: Any) -> Any:
|
||||||
if isinstance(value, UUID):
|
if isinstance(value, UUID):
|
||||||
return str(value)
|
return str(value)
|
||||||
if isinstance(value, (datetime, date)):
|
if isinstance(value, (datetime, date)):
|
||||||
@@ -245,9 +242,9 @@ def _serialize_value(key: str, value: Any) -> Any:
|
|||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
|
return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
|
||||||
if isinstance(value, dict):
|
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):
|
if isinstance(value, list):
|
||||||
return [_serialize_value("", item) for item in value]
|
return [_serialize_value(item) for item in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -314,11 +311,10 @@ def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, prefe
|
|||||||
return Path(normalized).as_posix()
|
return Path(normalized).as_posix()
|
||||||
|
|
||||||
|
|
||||||
def _prepare_photo_payload_and_uploads(
|
def _prepare_photo_payload_and_uploads( # noqa: PLR0915
|
||||||
*,
|
*,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
uploads_bundle_dir: Path,
|
uploads_bundle_dir: Path,
|
||||||
source_has_photo_table: bool,
|
|
||||||
legacy_portrait_rows: list[dict[str, Any]],
|
legacy_portrait_rows: list[dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
photo_rows = payload.setdefault("tables", {}).setdefault("photo", [])
|
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)
|
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:
|
for row in legacy_portrait_rows:
|
||||||
portrait_path = row.get("portrait_path")
|
portrait_path = row.get("portrait_path")
|
||||||
person_id = row.get("id")
|
person_id = row.get("id")
|
||||||
@@ -406,7 +402,8 @@ def _prepare_photo_payload_and_uploads(
|
|||||||
[
|
[
|
||||||
path
|
path
|
||||||
for path in legacy_homepage_dir.iterdir()
|
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),
|
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)
|
inspector = sqlalchemy_inspect(sync_connection)
|
||||||
table_names = set(inspector.get_table_names())
|
table_names = set(inspector.get_table_names())
|
||||||
if "source" in 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:
|
for row in rows:
|
||||||
original = str(row["file_path"])
|
original = str(row["file_path"])
|
||||||
normalized = _canonical_relative_path(original, preferred_prefix="documents/")
|
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
|
rows_changed += 1
|
||||||
return rows_changed
|
return rows_changed
|
||||||
|
|
||||||
|
|
||||||
async with active_engine.begin() as connection:
|
async with active_engine.begin() as connection:
|
||||||
rows_changed = await connection.run_sync(_reconcile)
|
rows_changed = await connection.run_sync(_reconcile)
|
||||||
if rows_changed:
|
if rows_changed:
|
||||||
@@ -177,7 +180,7 @@ def _canonical_relative_path(value: str, *, preferred_prefix: str) -> str | None
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
lowered = normalized.casefold()
|
lowered = normalized.casefold()
|
||||||
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
|
if lowered.startswith(("http://", "https://", "data:")):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if lowered.startswith("/uploads/"):
|
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 orm_attribute
|
||||||
from ..db.loading import selectinload
|
from ..db.loading import selectinload
|
||||||
from ..db.models import ExecutionAttempt
|
|
||||||
from ..db.models import Document
|
from ..db.models import Document
|
||||||
|
from ..db.models import ExecutionAttempt
|
||||||
from ..db.models import Job
|
from ..db.models import Job
|
||||||
from ..db.models import JobSource
|
from ..db.models import JobSource
|
||||||
from ..db.models import JobSourceStatus
|
from ..db.models import JobSourceStatus
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ from ..db.loading import orm_attribute
|
|||||||
from ..db.loading import selectinload
|
from ..db.loading import selectinload
|
||||||
from ..db.models import Document
|
from ..db.models import Document
|
||||||
from ..db.models import DocumentPerson
|
from ..db.models import DocumentPerson
|
||||||
from ..db.models import Photo
|
|
||||||
from ..db.models import Person
|
from ..db.models import Person
|
||||||
from ..db.models import PersonTag
|
|
||||||
from ..db.models import PersonRole
|
from ..db.models import PersonRole
|
||||||
|
from ..db.models import PersonTag
|
||||||
from ..db.models import Tag
|
from ..db.models import Tag
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
@@ -156,7 +155,11 @@ class PeopleService(ServiceBase):
|
|||||||
existing = await _session.get(
|
existing = await _session.get(
|
||||||
Person,
|
Person,
|
||||||
person.id,
|
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:
|
if existing is None:
|
||||||
raise self._not_found(f"Person with id {person.id} not found")
|
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 = () => {{
|
const buildInstance = () => {{
|
||||||
cleanup();
|
cleanup();
|
||||||
if (media && media.naturalWidth > 0 && media.naturalHeight > 0) {{
|
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, {{
|
const instance = Panzoom(target, {{
|
||||||
maxScale: 256,
|
maxScale: 256,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Presentation-only formatting shared by archival UI surfaces."""
|
"""Presentation-only formatting shared by archival UI surfaces."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from urllib.parse import quote_plus
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from urllib.parse import quote_plus
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from transcription.db.models import Person
|
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):
|
if lowered.startswith(_ABSOLUTE_SCHEMES):
|
||||||
return normalized
|
return normalized
|
||||||
if normalized.startswith(_UPLOAD_ROUTE_PREFIX):
|
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):
|
if lowered.startswith(_CANONICAL_PREFIXES):
|
||||||
return _resolve_upload_relative(normalized, upload_dir=upload_dir, base_url=base_url)
|
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):
|
if active_index[0] >= len(photos):
|
||||||
active_index[0] = len(photos) - 1
|
active_index[0] = len(photos) - 1
|
||||||
if active_index[0] < 0:
|
active_index[0] = max(active_index[0], 0)
|
||||||
active_index[0] = 0
|
|
||||||
|
|
||||||
current_photo = photos[active_index[0]]
|
current_photo = photos[active_index[0]]
|
||||||
dark_room_viewer(
|
dark_room_viewer(
|
||||||
@@ -137,7 +136,7 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
|
|||||||
ui.element("div")
|
ui.element("div")
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None: # noqa: PLR0915
|
||||||
"""Register the homepage routes."""
|
"""Register the homepage routes."""
|
||||||
|
|
||||||
@ui.page("/homepage", title="VibeScribe Home")
|
@ui.page("/homepage", title="VibeScribe Home")
|
||||||
@@ -173,7 +172,7 @@ def register_page() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@ui.page("/homepage/edit", title="Edit Homepage")
|
@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)
|
photos_service = PhotosService(session_factory=session_factory)
|
||||||
settings = resolve_runtime_settings(request)
|
settings = resolve_runtime_settings(request)
|
||||||
render_navigation_header(current_path="/homepage")
|
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"):
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||||
provider_options = [settings.provider.value]
|
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:
|
if locked_source is not None:
|
||||||
provider_input = (
|
provider_input = (
|
||||||
ui.select(provider_options, label="Provider", value=settings.provider.value)
|
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.config import Settings
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
from transcription.services.documents import DocumentService
|
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.people import PeopleError
|
from transcription.services.people import PeopleError
|
||||||
from transcription.services.people import PeopleService
|
from transcription.services.people import PeopleService
|
||||||
from transcription.services.photos import PhotoError
|
from transcription.services.photos import PhotoError
|
||||||
@@ -115,7 +115,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
render_people_table(rows)
|
render_people_table(rows)
|
||||||
|
|
||||||
@ui.page("/people/new")
|
@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)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
document_service = DocumentService(session_factory=session_factory)
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
@@ -230,7 +230,9 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
_render_person_biography_zone(person)
|
_render_person_biography_zone(person)
|
||||||
|
|
||||||
@ui.page("/people/{person_id}/photos")
|
@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)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
photos_service = PhotosService(session_factory=session_factory)
|
photos_service = PhotosService(session_factory=session_factory)
|
||||||
settings = resolve_runtime_settings(request)
|
settings = resolve_runtime_settings(request)
|
||||||
@@ -267,7 +269,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
def render_gallery() -> None:
|
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:
|
if not photos:
|
||||||
render_empty_state("No portrait photo uploaded yet.")
|
render_empty_state("No portrait photo uploaded yet.")
|
||||||
return
|
return
|
||||||
@@ -284,11 +286,13 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.image(photo_url).classes("w-full rounded-md")
|
ui.image(photo_url).classes("w-full rounded-md")
|
||||||
description_text = photo.description or "No description"
|
description_text = photo.description or "No description"
|
||||||
ui.label(description_text).classes(
|
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:
|
if photo.is_primary:
|
||||||
ui.label("Primary").classes(
|
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 = (
|
description_input = (
|
||||||
@@ -335,18 +339,24 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.navigate.to(f"/people/{person.id}/photos")
|
ui.navigate.to(f"/people/{person.id}/photos")
|
||||||
|
|
||||||
with ui.row().classes("w-full items-center gap-1"):
|
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:
|
if not photo.is_primary:
|
||||||
ui.button("Set Primary", on_click=set_primary, icon="star").props("flat dense")
|
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 ui.column().classes("w-full max-w-[1400px] mx-auto p-4 gap-4"):
|
||||||
with section_header_row():
|
with section_header_row():
|
||||||
page_header("Edit Photos", subtitle=f"{person.full_name} ({person.id})")
|
page_header("Edit Photos", subtitle=f"{person.full_name} ({person.id})")
|
||||||
with ui.row().classes("items-center gap-2"):
|
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(
|
ui.button(
|
||||||
"flat"
|
"Back to Person",
|
||||||
)
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).props("flat")
|
||||||
ui.upload(
|
ui.upload(
|
||||||
label="",
|
label="",
|
||||||
on_upload=on_photo_selected,
|
on_upload=on_photo_selected,
|
||||||
@@ -364,7 +374,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
render_gallery()
|
render_gallery()
|
||||||
|
|
||||||
@ui.page("/people/{person_id}/edit")
|
@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)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
document_service = DocumentService(session_factory=session_factory)
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
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)
|
photos = await photos_service.list_photos(person_id=person.id)
|
||||||
active_index = [0]
|
active_index = [0]
|
||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4"):
|
with ui.column().classes("col-span-12 lg:col-span-4"), archival_card(title="Photos", extra_classes="gap-3"):
|
||||||
with archival_card(title="Photos", extra_classes="gap-3"):
|
_render_photo_viewer_with_navigation(
|
||||||
_render_photo_viewer_with_navigation(
|
photos=photos,
|
||||||
photos=photos,
|
active_index=active_index,
|
||||||
active_index=active_index,
|
settings=settings,
|
||||||
settings=settings,
|
request=request,
|
||||||
request=request,
|
empty_message="No portrait photo uploaded yet.",
|
||||||
empty_message="No portrait photo uploaded yet.",
|
)
|
||||||
)
|
|
||||||
|
|
||||||
def _shift_gallery_index(*, photos: list, active_index: list[int], step: int) -> None:
|
def _shift_gallery_index(*, photos: list, active_index: list[int], step: int) -> None:
|
||||||
if len(photos) < 2:
|
if len(photos) < 2:
|
||||||
@@ -655,8 +665,7 @@ def _render_photo_viewer_with_navigation(
|
|||||||
|
|
||||||
if active_index[0] >= len(photos):
|
if active_index[0] >= len(photos):
|
||||||
active_index[0] = len(photos) - 1
|
active_index[0] = len(photos) - 1
|
||||||
if active_index[0] < 0:
|
active_index[0] = max(active_index[0], 0)
|
||||||
active_index[0] = 0
|
|
||||||
|
|
||||||
current_photo = photos[active_index[0]]
|
current_photo = photos[active_index[0]]
|
||||||
photo_url = resolve_media_url(
|
photo_url = resolve_media_url(
|
||||||
|
|||||||
@@ -398,7 +398,11 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
if not summaries_outcome.ok:
|
if not summaries_outcome.ok:
|
||||||
return
|
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:
|
if not summaries:
|
||||||
render_empty_state("No editable Markdown prompts were found.")
|
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 EvidenceService
|
||||||
from transcription.services.evidence import LatestExecutionAttempt
|
from transcription.services.evidence import LatestExecutionAttempt
|
||||||
from transcription.services.source_media import lookup_source_mime_type
|
from transcription.services.source_media import lookup_source_mime_type
|
||||||
|
from transcription.services.sources import SourceNavigation
|
||||||
from transcription.services.sources import SourceService
|
from transcription.services.sources import SourceService
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
@@ -652,7 +653,7 @@ def _render_machine_candidates(
|
|||||||
successful = [
|
successful = [
|
||||||
attempt
|
attempt
|
||||||
for attempt in attempts
|
for attempt in attempts
|
||||||
if attempt.status.value == "transcribed" and attempt.raw_transcription
|
if attempt.status == JobSourceStatus.TRANSCRIBED and attempt.raw_transcription
|
||||||
]
|
]
|
||||||
candidates = [
|
candidates = [
|
||||||
attempt for attempt in successful if attempt.id != source.preferred_execution_attempt_id
|
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 fastapi.testclient import TestClient
|
||||||
from sqlmodel import select
|
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_document_service
|
||||||
from transcription.api.documents_api import get_people_service
|
from transcription.api.documents_api import get_people_service
|
||||||
from transcription.api.documents_api import router
|
from transcription.api.documents_api import router
|
||||||
|
from transcription.api.errors import register_error_handlers
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import SqliteSettings
|
from transcription.config import SqliteSettings
|
||||||
from transcription.db import create_all
|
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 Job
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import PersonTag
|
from transcription.db.models import PersonTag
|
||||||
|
from transcription.db.models import Photo
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.db.models import Tag
|
from transcription.db.models import Tag
|
||||||
from transcription.db.models import Photo
|
|
||||||
from transcription.services.documents import DocumentDeleteBlockedError
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
from transcription.services.documents import DocumentError
|
from transcription.services.documents import DocumentError
|
||||||
from transcription.services.documents import DocumentService
|
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):
|
async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory):
|
||||||
people_service = PeopleService(session_factory=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"
|
assert created.family_search_id == "G8T4-MDQ"
|
||||||
|
|
||||||
with pytest.raises(PeopleError) as duplicate:
|
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
|
assert duplicate.value.category == ErrorCategory.CONFLICT
|
||||||
|
|
||||||
with pytest.raises(PeopleError) as malformed:
|
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
|
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 UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlmodel import SQLModel
|
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 MigrationPaths
|
||||||
from transcription.db.migration import export_bundle
|
from transcription.db.migration import export_bundle
|
||||||
from transcription.db.migration import import_bundle
|
from transcription.db.migration import import_bundle
|
||||||
from transcription.db.migration import migrate_via_bundle
|
from transcription.db.migration import migrate_via_bundle
|
||||||
from transcription.db.migration import sqlite_url_from_path
|
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):
|
def test_export_import_migration_round_trips_db_and_uploads(tmp_path):
|
||||||
source_db_path = tmp_path / "source.db"
|
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},
|
{"id": person_id},
|
||||||
).one()
|
).one()
|
||||||
photos = connection.execute(
|
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()
|
).all()
|
||||||
assert person_name[0] == "Legacy"
|
assert person_name[0] == "Legacy"
|
||||||
assert person_name[1] == "Portrait"
|
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)
|
target_engine = create_engine(target_db_url)
|
||||||
try:
|
try:
|
||||||
with target_engine.connect() as connection:
|
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
|
# stale row must not survive; legacy portrait + homepage should be backfilled
|
||||||
assert len(photos) == 2
|
assert len(photos) == 2
|
||||||
assert any(row[0] is not None for row in photos)
|
assert any(row[0] is not None for row in photos)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for the documents page routes and action handlers."""
|
"""Tests for the documents page routes and action handlers."""
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
import re
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@@ -10,13 +10,13 @@ from sqlmodel import select
|
|||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import DocumentPerson
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentTag
|
||||||
from transcription.db.models import DocumentType
|
from transcription.db.models import DocumentType
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import PersonRole
|
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 Source
|
||||||
|
from transcription.db.models import Tag
|
||||||
from transcription.ui.pages.documents_page import _resolve_selected_tag_labels
|
from transcription.ui.pages.documents_page import _resolve_selected_tag_labels
|
||||||
|
|
||||||
# --- Helper Fixtures ---
|
# --- Helper Fixtures ---
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ from transcription.db import session_scope
|
|||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import DocumentPerson
|
from transcription.db.models import DocumentPerson
|
||||||
from transcription.db.models import Person
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import PersonTag
|
|
||||||
from transcription.db.models import PersonRole
|
from transcription.db.models import PersonRole
|
||||||
|
from transcription.db.models import PersonTag
|
||||||
from transcription.db.models import Photo
|
from transcription.db.models import Photo
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.db.models import Tag
|
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
|
uv run python tools/export_import_migration.py export --bundle-dir .migration-bundle
|
||||||
|
|
||||||
2) Import bundle into a fresh target DB + uploads root:
|
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:
|
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
|
from __future__ import annotations
|
||||||
|
|||||||
Reference in New Issue
Block a user