From 26f9c83f54c691fdb1ae041235645d5e181b603c Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:17:44 -0500 Subject: [PATCH] chore: make ty check blocking with targeted suppressions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .pre-commit-config.yaml | 9 ++++----- docs/production-runbook.md | 14 ++++++++++++++ src/transcription/services/photos.py | 20 ++++++++++++++------ tests/test_storage_reconciliation.py | 9 ++++++++- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ae565c..6545baa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,5 @@ -# 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. +# Quality gate for V4.6 [HIGH-06]. `ruff check`, `ruff format --check`, and `ty check` +# are blocking once known `ty` false positives are suppressed inline with rationale. # # Both tools are uv-managed dev dependencies and are not on PATH, so each entry must # go through `uv run`. @@ -21,8 +20,8 @@ repos: pass_filenames: false require_serial: true - id: ty - name: ty check (advisory) - entry: python -c "import subprocess, sys; subprocess.run(['uv', 'run', 'ty', 'check']); sys.exit(0)" + name: ty check + entry: uv run ty check language: system types_or: [python, pyi] pass_filenames: false diff --git a/docs/production-runbook.md b/docs/production-runbook.md index 4cc3f02..8542e01 100644 --- a/docs/production-runbook.md +++ b/docs/production-runbook.md @@ -113,3 +113,17 @@ Declared with `>=` floors and moved by explicit `uv lock --upgrade`. Verify with `uv run ruff check .`, `uv run ty check`, and `uv run pytest -q -m "not external"` before committing a changed lockfile. +## 7. Type-check suppression policy + +`uv run ty check` is a blocking pre-commit gate. Suppressions are allowed only for +proven SQLAlchemy descriptor false positives where runtime behavior is correct and +the checker cannot represent the descriptor protocol at that call site. + +Every suppression must be: + +1. **Targeted** to a single rule (for example `# ty: ignore[unresolved-attribute]`). +2. **Inline** on the expression it suppresses (not file-wide). +3. Followed by a **one-line rationale** stating it is a SQLAlchemy descriptor false positive. + +Do not use broad or rationale-free suppressions. If a diagnostic is not a known +false positive, fix the code instead of suppressing it. diff --git a/src/transcription/services/photos.py b/src/transcription/services/photos.py index 592f13d..b356d91 100644 --- a/src/transcription/services/photos.py +++ b/src/transcription/services/photos.py @@ -178,25 +178,33 @@ class PhotosService(ServiceBase): async def _list_owner_photos(self, *, session: AsyncSession, person_id: UUID | None) -> list[Photo]: query = select(Photo) if person_id is None: - query = query.where(Photo.person_id.is_(None)) + query = query.where(Photo.person_id.is_(None)) # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. else: query = query.where(Photo.person_id == person_id) - query = query.order_by(Photo.created_at.asc(), Photo.id.asc()) + query = query.order_by( + Photo.created_at.asc(), # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. + Photo.id.asc(), # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. + ) return list((await session.exec(query)).all()) async def _owner_oldest_photo(self, *, session: AsyncSession, person_id: UUID | None) -> Photo | None: query = select(Photo) if person_id is None: - query = query.where(Photo.person_id.is_(None)) + query = query.where(Photo.person_id.is_(None)) # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. else: query = query.where(Photo.person_id == person_id) - query = query.order_by(Photo.created_at.asc(), Photo.id.asc()).limit(1) + query = query.order_by( + Photo.created_at.asc(), # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. + Photo.id.asc(), # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. + ).limit(1) return (await session.exec(query)).first() async def _clear_owner_primary(self, *, session: AsyncSession, person_id: UUID | None) -> None: - query = select(Photo).where(Photo.is_primary.is_(True)) + query = select(Photo).where( + Photo.is_primary.is_(True) # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. + ) if person_id is None: - query = query.where(Photo.person_id.is_(None)) + query = query.where(Photo.person_id.is_(None)) # ty: ignore[unresolved-attribute] - SQLAlchemy descriptor false positive. else: query = query.where(Photo.person_id == person_id) for current in (await session.exec(query)).all(): diff --git a/tests/test_storage_reconciliation.py b/tests/test_storage_reconciliation.py index d0cb254..06d9dac 100644 --- a/tests/test_storage_reconciliation.py +++ b/tests/test_storage_reconciliation.py @@ -35,7 +35,14 @@ async def _document_ids(settings: Settings) -> set[str]: async def _source_counts_by_document(settings: Settings) -> dict[str, int]: async with session_scope(settings=settings) as session: - rows = await session.exec(select(Source.document_id, func.count(Source.id)).group_by(Source.document_id)) + rows = await session.exec( + select( + Source.document_id, + func.count(Source.id), # ty: ignore[invalid-argument-type] - SQLAlchemy descriptor false positive. + ).group_by( + Source.document_id # ty: ignore[invalid-argument-type] - SQLAlchemy descriptor false positive. + ) + ) return {str(document_id): int(count) for document_id, count in rows}