generated from john/python-template
Baseline was 207 diagnostics. Two real bugs were hiding in the noise: - tools/run_destructive_tests.py imported ctypes.wintypes at module scope, which raises on non-Windows, and called fcntl unconditionally. The Windows and POSIX implementations now live under a module-level sys.platform split. - tests/ui/test_sources_page.py constructed Source(...) without document_id. Structural fixes, not suppressions: - New src/transcription/db/loading.py owns the SQLModel-field to QueryableAttribute reinterpretation via orm_attribute()/selectinload()/ defer(). This removed 42 "# pyright: ignore[reportArgumentType]" comments across documents/jobs/people/sources. Its docstring records that selectinload(A.b, B.c) is NOT equivalent to the chained form: varargs applies the selectin strategy only to the last path element, which under lazy="raise" raises InvalidRequestError at render time. - db/session.py transaction_scope no longer accepts or yields AsyncSessionTransaction. No caller ever passed one, sessionmaker.begin() yields an AsyncSession, and the dead branch was latently buggy because services call .exec(). Cleared 7 workflows.py diagnostics. - services/registry.py RegistryService is bound by a new RegistryEntry Protocol instead of bare SQLModel, so the shared implementation can read id/label/normalized_label/is_active. Cleared 9 diagnostics. - Column expressions in sources.py/jobs.py/test_store.py wrap in sqlmodel col(), the idiom already used in registry.py. - read_source_navigation wraps its literal tuple bounds in literal(). - normalization.py narrows with isinstance(image, TiffImageFile) rather than comparing image.format, since tag_v2 is TIFF-only. - linked_people.render uses @ui.refreshable_method, the NiceGUI API for bound methods. - The OpenRouter capturing client re-raises ResponseNotRead when the response stream is not async rather than mis-wrapping it. Tooling gate: - New .pre-commit-config.yaml runs ruff check and ty check as blocking hooks. No pre-commit config previously existed. Negative-tested: injecting a type error fails both hooks. - The last two "# pyright: ignore" comments (config.py) are removed; ty does not honor pyright directives. One "# ty: ignore" remains, in tests/test_prompts.py, where the test deliberately assigns to a frozen field to assert ValidationError. - asyncio_default_fixture_loop_scope is pinned to "function" so pytest-asyncio behavior does not shift on upgrade. Verification: ruff check clean, ty check reports 0 diagnostics, 292 passed and 4 skipped, pre-commit passes and demonstrably fails on a regression, and tools/run_destructive_tests.py runs on Windows. Co-authored-by: Copilot App <[email protected]>
188 lines
6.9 KiB
Python
188 lines
6.9 KiB
Python
"""Tests for the database runtime and V2 schema bootstrap behavior."""
|
|
|
|
import warnings
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import inspect
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.exc import SAWarning
|
|
from sqlmodel import SQLModel
|
|
from sqlmodel import select
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from transcription.config import Settings
|
|
from transcription.config import SqliteSettings
|
|
from transcription.db import create_all
|
|
from transcription.db import dispose_database_runtime
|
|
from transcription.db import initialize_database_runtime
|
|
from transcription.db import session_scope
|
|
from transcription.db.models import DocumentType
|
|
from transcription.db.models import PersonRole
|
|
from transcription.db.models import Source
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_all_creates_expected_tables(tmp_path):
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "schema.db")),
|
|
environment="test",
|
|
)
|
|
runtime = initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
await create_all(engine=runtime.engine)
|
|
async with runtime.engine.connect() as conn:
|
|
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
|
|
|
|
assert "document" in table_names
|
|
assert "document_type" in table_names
|
|
assert "person" in table_names
|
|
assert "person_role" in table_names
|
|
assert "document_person" in table_names
|
|
assert "job" in table_names
|
|
assert "source" in table_names
|
|
assert "job_source" in table_names
|
|
assert "execution_attempt" in table_names
|
|
assert "processing_artifact" in table_names
|
|
assert "revision" not in table_names
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_yields_async_session(tmp_path):
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "session.db")),
|
|
environment="test",
|
|
)
|
|
initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
async with session_scope(settings=settings) as session:
|
|
assert session is not None
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_rejects_reinitialization_for_different_database(tmp_path):
|
|
"""An existing process runtime cannot silently switch database targets."""
|
|
first_settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "first.db")),
|
|
environment="test",
|
|
)
|
|
second_settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "second.db")),
|
|
environment="test",
|
|
)
|
|
initialize_database_runtime(settings=first_settings)
|
|
|
|
try:
|
|
with pytest.raises(RuntimeError, match="already initialized for a different database"):
|
|
initialize_database_runtime(settings=second_settings)
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_all_seeds_default_registry_rows(tmp_path):
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "seed.db")),
|
|
environment="test",
|
|
)
|
|
runtime = initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
await create_all(engine=runtime.engine)
|
|
async with AsyncSession(runtime.engine, expire_on_commit=False) as session:
|
|
role_keys = set((await session.exec(select(PersonRole.semantic_key))).all())
|
|
type_keys = set((await session.exec(select(DocumentType.semantic_key))).all())
|
|
|
|
assert {"author", "recipient", "mentioned"}.issubset(role_keys)
|
|
assert {"book", "letter", "postcard", "photo", "journal", "form"}.issubset(type_keys)
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_all_declares_hot_path_indexes(tmp_path):
|
|
"""Worker and detail-page filters must be index-backed in a freshly created schema."""
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
database=SqliteSettings(path=str(tmp_path / "indexes.db")),
|
|
environment="test",
|
|
)
|
|
runtime = initialize_database_runtime(settings=settings)
|
|
|
|
try:
|
|
await create_all(engine=runtime.engine)
|
|
async with runtime.engine.connect() as connection:
|
|
|
|
def collect(sync_connection) -> dict[str, list[list[str]]]:
|
|
database = inspect(sync_connection)
|
|
return {
|
|
table: [index["column_names"] for index in database.get_indexes(table)]
|
|
for table in ("job", "source", "job_source", "document", "document_person")
|
|
}
|
|
|
|
indexes = await connection.run_sync(collect)
|
|
|
|
assert ["status", "date_created"] in indexes["job"]
|
|
assert ["document_id"] in indexes["job"]
|
|
assert ["document_id"] in indexes["source"]
|
|
assert ["preferred_execution_attempt_id"] in indexes["source"]
|
|
assert ["job_id"] in indexes["job_source"]
|
|
assert ["source_id"] in indexes["job_source"]
|
|
assert ["document_type_id"] in indexes["document"]
|
|
for column in ("document_id", "person_id", "role_id"):
|
|
assert [column] in indexes["document_person"]
|
|
finally:
|
|
await dispose_database_runtime()
|
|
|
|
|
|
def test_metadata_has_no_unresolvable_table_cycle():
|
|
"""create_all must be able to order every table, including on PostgreSQL."""
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error", SAWarning)
|
|
ordered = [table.name for table in SQLModel.metadata.sorted_tables]
|
|
|
|
assert ordered.index("source") < ordered.index("execution_attempt")
|
|
|
|
|
|
def test_preferred_execution_attempt_id_column_matches_model_declaration():
|
|
"""The self-referential provenance column is a real UUID, not an opaque CHAR(32)."""
|
|
column = SQLModel.metadata.tables["source"].c["preferred_execution_attempt_id"]
|
|
|
|
assert isinstance(column.type, sa.Uuid)
|
|
assert column.type.compile(dialect=postgresql.dialect()) == "UUID"
|
|
assert Source.model_fields["preferred_execution_attempt_id"].annotation is not None
|
|
|
|
foreign_key = next(iter(column.foreign_keys))
|
|
assert foreign_key.use_alter is True
|
|
assert foreign_key.column is SQLModel.metadata.tables["execution_attempt"].c["id"]
|
|
|
|
|
|
def test_bootstrap_policy_production_defaults_false():
|
|
settings = Settings(openrouter_api_key="test-key", environment="production")
|
|
assert settings.should_bootstrap_schema is False
|
|
|
|
|
|
def test_bootstrap_policy_development_defaults_true():
|
|
settings = Settings(openrouter_api_key="test-key", environment="development")
|
|
assert settings.should_bootstrap_schema is True
|
|
|
|
|
|
def test_bootstrap_policy_explicit_override_true():
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
environment="production",
|
|
bootstrap_schema_on_startup=True,
|
|
)
|
|
assert settings.should_bootstrap_schema is True
|