4 Commits
Author SHA1 Message Date
Jim LancasterandCopilot App 736d0c06f4 chore: enable logging format lint rule
Quality Gate / gate (push) Failing after 48s
Co-authored-by: Copilot App <[email protected]>
2026-08-23 18:19:05 -05:00
Jim LancasterandCopilot App 26f9c83f54 chore: make ty check blocking with targeted suppressions
Co-authored-by: Copilot App <[email protected]>
2026-08-23 18:17:44 -05:00
Jim LancasterandCopilot App a2bb1acd6b chore: enforce ruff format in pre-commit
Co-authored-by: Copilot App <[email protected]>
2026-08-23 18:14:52 -05:00
Jim LancasterandCopilot App 2a56365847 style: apply ruff formatting sweep
Co-authored-by: Copilot App <[email protected]>
2026-08-23 18:13:38 -05:00
42 changed files with 188 additions and 241 deletions
+11 -5
View File
@@ -1,6 +1,5 @@
# Quality gate for V4.6 [HIGH-06]. `ruff check` is blocking. `ty check` is advisory # Quality gate for V4.6 [HIGH-06]. `ruff check`, `ruff format --check`, and `ty check`
# during release stabilization: it reports its whole-project baseline without failing # are blocking once known `ty` false positives are suppressed inline with rationale.
# 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 # Both tools are uv-managed dev dependencies and are not on PATH, so each entry must
# go through `uv run`. # go through `uv run`.
@@ -13,9 +12,16 @@ repos:
language: system language: system
types_or: [python, pyi] types_or: [python, pyi]
require_serial: true require_serial: true
- id: ruff-format
name: ruff format check
entry: uv run ruff format --check .
language: system
types_or: [python, pyi]
pass_filenames: false
require_serial: true
- id: ty - id: ty
name: ty check (advisory) name: ty check
entry: python -c "import subprocess, sys; subprocess.run(['uv', 'run', 'ty', 'check']); sys.exit(0)" entry: uv run ty check
language: system language: system
types_or: [python, pyi] types_or: [python, pyi]
pass_filenames: false pass_filenames: false
+14
View File
@@ -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"` `uv run ruff check .`, `uv run ty check`, and `uv run pytest -q -m "not external"`
before committing a changed lockfile. 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.
+3
View File
@@ -384,6 +384,7 @@ The critical gap is transaction atomicity (HIGH-04) — the audit verdict is **"
```python ```python
# src/transcription/services/base.py # src/transcription/services/base.py
@asynccontextmanager @asynccontextmanager
async def unit_of_work( async def unit_of_work(
services: ServiceBundle, services: ServiceBundle,
@@ -396,10 +397,12 @@ async def unit_of_work(
HIGH-01 structurally hard to reintroduce. HIGH-01 structurally hard to reintroduce.
""" """
async def run_blocking[T](fn: Callable[[], T]) -> T: async def run_blocking[T](fn: Callable[[], T]) -> T:
"""Run a CPU- or disk-bound callable off the event loop.""" """Run a CPU- or disk-bound callable off the event loop."""
return await asyncio.to_thread(fn) return await asyncio.to_thread(fn)
async def insert_with_sequence_retry( async def insert_with_sequence_retry(
session: AsyncSession, session: AsyncSession,
*, *,
+1
View File
@@ -26,6 +26,7 @@ extend-select = [
"E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb "FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"I", # https://docs.astral.sh/ruff/rules/#isort-i "I", # https://docs.astral.sh/ruff/rules/#isort-i
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n "N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
"PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd "PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd
+1 -1
View File
@@ -207,7 +207,7 @@ LOGGING_CONFIG: dict[str, Any] = {
"maxBytes": 10 * 1024 * 1024, "maxBytes": 10 * 1024 * 1024,
"backupCount": 5, "backupCount": 5,
"encoding": "utf-8", "encoding": "utf-8",
} },
}, },
"root": { "root": {
"level": "INFO", "level": "INFO",
+8 -10
View File
@@ -93,11 +93,15 @@ def export_bundle(*, source_db_url: str, source_upload_dir: Path, bundle_dir: Pa
if legacy_column not in export_columns: if legacy_column not in export_columns:
export_columns.append(legacy_column) export_columns.append(legacy_column)
if table_name == "person" and "portrait_path" in source_table.columns: if table_name == "person" and "portrait_path" in source_table.columns:
legacy_portrait_rows = connection.execute( legacy_portrait_rows = (
connection.execute(
select(source_table.c["id"], source_table.c["portrait_path"]).where( select(source_table.c["id"], source_table.c["portrait_path"]).where(
source_table.c["portrait_path"].is_not(None) source_table.c["portrait_path"].is_not(None)
) )
).mappings().all() )
.mappings()
.all()
)
rows = connection.execute(select(*(source_table.c[name] for name in export_columns))).mappings().all() rows = connection.execute(select(*(source_table.c[name] for name in export_columns))).mappings().all()
payload["tables"][table_name] = [ payload["tables"][table_name] = [
_serialize_row(row, table_name=table_name, source_upload_dir=source_upload_dir) for row in rows _serialize_row(row, table_name=table_name, source_upload_dir=source_upload_dir) for row in rows
@@ -344,15 +348,9 @@ def _prepare_photo_payload_and_uploads( # noqa: PLR0915
photo_rows[:] = retained_rows photo_rows[:] = retained_rows
existing_homepage_rows = [row for row in photo_rows if row.get("person_id") is None] existing_homepage_rows = [row for row in photo_rows if row.get("person_id") is None]
existing_person_ids = { existing_person_ids = {str(row["person_id"]) for row in photo_rows if row.get("person_id") is not None}
str(row["person_id"])
for row in photo_rows
if row.get("person_id") is not None
}
existing_primary_person_ids = { existing_primary_person_ids = {
str(row["person_id"]) str(row["person_id"]) for row in photo_rows if row.get("person_id") is not None and bool(row.get("is_primary"))
for row in photo_rows
if row.get("person_id") is not None and bool(row.get("is_primary"))
} }
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)
+1 -3
View File
@@ -435,9 +435,7 @@ class Source(SQLModel, table=True):
""" """
job_sources = _loaded_attribute(self, "job_sources") or () job_sources = _loaded_attribute(self, "job_sources") or ()
dated = [ dated = [
(job, job_source) (job, job_source) for job_source in job_sources if (job := _loaded_attribute(job_source, "job")) is not None
for job_source in job_sources
if (job := _loaded_attribute(job_source, "job")) is not None
] ]
if dated: if dated:
return max(dated, key=lambda pair: pair[0].date_created)[1] return max(dated, key=lambda pair: pair[0].date_created)[1]
+1 -2
View File
@@ -50,8 +50,7 @@ def initialize_database_runtime(*, settings: Settings | None = None) -> Database
runtime_url = runtime.engine.url.render_as_string(hide_password=False) runtime_url = runtime.engine.url.render_as_string(hide_password=False)
if runtime_url != database_url: if runtime_url != database_url:
raise RuntimeError( raise RuntimeError(
"Database runtime is already initialized for a different database: " f"Database runtime is already initialized for a different database: {runtime_url!r} != {database_url!r}"
f"{runtime_url!r} != {database_url!r}"
) )
return runtime return runtime
+2 -5
View File
@@ -582,8 +582,7 @@ class DocumentService(ServiceBase):
) )
existing_tags = ( existing_tags = (
(await _session.exec(select(Tag).where(col(Tag.normalized_label).in_(label_keys)))) (await _session.exec(select(Tag).where(col(Tag.normalized_label).in_(label_keys)))).all()
.all()
if label_keys if label_keys
else [] else []
) )
@@ -598,9 +597,7 @@ class DocumentService(ServiceBase):
tags_by_key[key] = tag tags_by_key[key] = tag
selected_tag_ids.add(tag.id) selected_tag_ids.add(tag.id)
links = ( links = (await _session.exec(select(DocumentTag).where(DocumentTag.document_id == document_id))).all()
await _session.exec(select(DocumentTag).where(DocumentTag.document_id == document_id))
).all()
existing_ids = {link.tag_id for link in links} existing_ids = {link.tag_id for link in links}
for link in links: for link in links:
+2 -8
View File
@@ -278,9 +278,7 @@ class JobService(ServiceBase):
) )
attempt_count = ( attempt_count = (
await _session.exec( await _session.exec(
select(func.count()) select(func.count()).select_from(ExecutionAttempt).where(ExecutionAttempt.job_id == job_id)
.select_from(ExecutionAttempt)
.where(ExecutionAttempt.job_id == job_id)
) )
).one() ).one()
if attempt_count: if attempt_count:
@@ -320,11 +318,7 @@ class JobService(ServiceBase):
) )
attempts = list( attempts = list(
( (await session.exec(select(ExecutionAttempt).where(ExecutionAttempt.job_id == job_id))).all()
await session.exec(
select(ExecutionAttempt).where(ExecutionAttempt.job_id == job_id)
)
).all()
) )
for attempt in attempts: for attempt in attempts:
await session.delete(attempt) await session.delete(attempt)
+5 -1
View File
@@ -67,10 +67,14 @@ async def persist_named_media(
) -> Path: ) -> Path:
"""Resolve a target directory/name and persist media bytes safely.""" """Resolve a target directory/name and persist media bytes safely."""
target_dir = root if namespace is None else root / Path(namespace) target_dir = root if namespace is None else root / Path(namespace)
stored_name = Path(filename).name if preserve_original_name else build_stored_filename( stored_name = (
Path(filename).name
if preserve_original_name
else build_stored_filename(
filename=filename, filename=filename,
filename_stem=filename_stem, filename_stem=filename_stem,
) )
)
return await write_media_bytes( return await write_media_bytes(
target_dir=target_dir, target_dir=target_dir,
stored_name=stored_name, stored_name=stored_name,
+1 -2
View File
@@ -277,8 +277,7 @@ class PeopleService(ServiceBase):
) )
existing_tags = ( existing_tags = (
(await _session.exec(select(Tag).where(col(Tag.normalized_label).in_(label_keys)))) (await _session.exec(select(Tag).where(col(Tag.normalized_label).in_(label_keys)))).all()
.all()
if label_keys if label_keys
else [] else []
) )
+14 -6
View File
@@ -178,25 +178,33 @@ class PhotosService(ServiceBase):
async def _list_owner_photos(self, *, session: AsyncSession, person_id: UUID | None) -> list[Photo]: async def _list_owner_photos(self, *, session: AsyncSession, person_id: UUID | None) -> list[Photo]:
query = select(Photo) query = select(Photo)
if person_id is None: 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: else:
query = query.where(Photo.person_id == person_id) 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()) return list((await session.exec(query)).all())
async def _owner_oldest_photo(self, *, session: AsyncSession, person_id: UUID | None) -> Photo | None: async def _owner_oldest_photo(self, *, session: AsyncSession, person_id: UUID | None) -> Photo | None:
query = select(Photo) query = select(Photo)
if person_id is None: 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: else:
query = query.where(Photo.person_id == person_id) 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() return (await session.exec(query)).first()
async def _clear_owner_primary(self, *, session: AsyncSession, person_id: UUID | None) -> None: 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: 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: else:
query = query.where(Photo.person_id == person_id) query = query.where(Photo.person_id == person_id)
for current in (await session.exec(query)).all(): for current in (await session.exec(query)).all():
+1 -3
View File
@@ -65,9 +65,7 @@ def analyze_transcription_quality(text: str) -> tuple[QualityWarning, ...]:
warnings.append( warnings.append(
QualityWarning( QualityWarning(
code=QualityWarningCode.REDUNDANT_HANDWRITING_WRAPPERS, code=QualityWarningCode.REDUNDANT_HANDWRITING_WRAPPERS,
detail=( detail=("A wholly handwritten document also uses repeated whole-line handwriting wrappers."),
"A wholly handwritten document also uses repeated whole-line handwriting wrappers."
),
) )
) )
+2 -4
View File
@@ -249,8 +249,7 @@ class RegistryService[ModelT: RegistryEntry](ServiceBase):
f"Built-in {self.noun} {entry.label!r} cannot be deleted", f"Built-in {self.noun} {entry.label!r} cannot be deleted",
category=ErrorCategory.CONFLICT, category=ErrorCategory.CONFLICT,
suggestion=( suggestion=(
f"Deactivate the {self.short_noun} instead; " f"Deactivate the {self.short_noun} instead; its built-in meaning must remain available."
"its built-in meaning must remain available."
), ),
) )
if await self._is_referenced(session=_session, entry=entry): if await self._is_referenced(session=_session, entry=entry):
@@ -258,8 +257,7 @@ class RegistryService[ModelT: RegistryEntry](ServiceBase):
f"{self.noun} {entry.label!r} is referenced and cannot be deleted", f"{self.noun} {entry.label!r} is referenced and cannot be deleted",
category=ErrorCategory.CONFLICT, category=ErrorCategory.CONFLICT,
suggestion=( suggestion=(
f"Deactivate the {self.short_noun} instead; " f"Deactivate the {self.short_noun} instead; {self.referenced_retainer} will retain it."
f"{self.referenced_retainer} will retain it."
), ),
) )
await _session.delete(entry) await _session.delete(entry)
+3 -11
View File
@@ -351,9 +351,7 @@ class SourceService(ServiceBase):
async with self._session_scope(session) as _session: async with self._session_scope(session) as _session:
job_source = ( job_source = (
await _session.exec( await _session.exec(
select(JobSource) select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
.where(JobSource.job_id == job_id)
.where(JobSource.source_id == source_id)
) )
).first() ).first()
if job_source is None: if job_source is None:
@@ -400,9 +398,7 @@ class SourceService(ServiceBase):
linked_job_sources = list(source.job_sources) linked_job_sources = list(source.job_sources)
attempt_count = ( attempt_count = (
await _session.exec( await _session.exec(
select(func.count()) select(func.count()).select_from(ExecutionAttempt).where(ExecutionAttempt.source_id == source_id)
.select_from(ExecutionAttempt)
.where(ExecutionAttempt.source_id == source_id)
) )
).one() ).one()
if attempt_count: if attempt_count:
@@ -586,11 +582,7 @@ class SourceService(ServiceBase):
_session.add(attempt) _session.add(attempt)
await _session.flush() await _session.flush()
if ( if text is not None and source.raw_transcription is None and source.preferred_execution_attempt_id is None:
text is not None
and source.raw_transcription is None
and source.preferred_execution_attempt_id is None
):
source.raw_transcription = text source.raw_transcription = text
source.preferred_execution_attempt_id = attempt.id source.preferred_execution_attempt_id = attempt.id
+2 -2
View File
@@ -190,7 +190,7 @@ async def advance_job(
session=session, session=session,
) )
else: else:
logger.error(f"Job {job.id} has failed and reached max retries.") logger.error("Job %s has failed and reached max retries.", job.id)
return return
case _: case _:
return return
@@ -207,7 +207,7 @@ async def process_queued_job( # noqa: PLR0915
runtime_settings = settings or get_settings() runtime_settings = settings or get_settings()
current_status = _coerce_job_status(job.status) current_status = _coerce_job_status(job.status)
if current_status not in {JobStatus.QUEUED, JobStatus.PROCESSING}: if current_status not in {JobStatus.QUEUED, JobStatus.PROCESSING}:
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}") logger.warning("Job %s is not queued. Current status: %s", job.id, job.status)
return return
# Transaction A: claim job for processing. Reached only when a caller hands us a # Transaction A: claim job for processing. Reached only when a caller hands us a
@@ -43,8 +43,4 @@ def render_upload_picker(
props.append("webkitdirectory directory") props.append("webkitdirectory directory")
if multiple: if multiple:
props.append("multiple") props.append("multiple")
return ( return ui.upload(on_upload=on_upload, auto_upload=True, label=label).props(" ".join(props)).classes("w-full")
ui.upload(on_upload=on_upload, auto_upload=True, label=label)
.props(" ".join(props))
.classes("w-full")
)
+7 -5
View File
@@ -377,9 +377,7 @@ def register_page() -> None: # noqa: PLR0915
if document.sources or document.jobs: if document.sources or document.jobs:
render_delete_blocked_notice( render_delete_blocked_notice(
reason="Delete is blocked because related records exist.", reason="Delete is blocked because related records exist.",
detail=dependency_summary( detail=dependency_summary([("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]),
[("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]
),
guidance="Remove related records first, then retry deletion.", guidance="Remove related records first, then retry deletion.",
back_label="Back to Document", back_label="Back to Document",
back_target=f"/documents/{document.id}", back_target=f"/documents/{document.id}",
@@ -497,14 +495,18 @@ def _render_document_form_fields(
if document is not None if document is not None
else [] else []
) )
tags_input = ui.select( tags_input = (
ui.select(
sorted(tag_options, key=str.casefold), sorted(tag_options, key=str.casefold),
label="Tags", label="Tags",
value=selected_tags, value=selected_tags,
multiple=True, multiple=True,
with_input=True, with_input=True,
new_value_mode="add-unique", new_value_mode="add-unique",
).props("outlined use-chips").classes("w-full ui-form-surface") )
.props("outlined use-chips")
.classes("w-full ui-form-surface")
)
linked_people.render() linked_people.render()
+13 -4
View File
@@ -88,6 +88,7 @@ def _render_homepage_gallery(
ui.label(f"{active_index[0] + 1} of {len(photos)}").classes("text-xs ui-text-muted") ui.label(f"{active_index[0] + 1} of {len(photos)}").classes("text-xs ui-text-muted")
if enable_rotation and rotate_enabled is not None: if enable_rotation and rotate_enabled is not None:
def set_rotation(enabled: bool) -> None: def set_rotation(enabled: bool) -> None:
rotate_enabled[0] = enabled rotate_enabled[0] = enabled
if on_change is not None: if on_change is not None:
@@ -128,10 +129,14 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
render_image_panel() render_image_panel()
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"): with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
markdown_input[0] = ui.textarea( markdown_input[0] = (
ui.textarea(
label="Homepage markdown", label="Homepage markdown",
value=read_homepage_markdown(), value=read_homepage_markdown(),
).props("outlined autogrow").classes("w-full") )
.props("outlined autogrow")
.classes("w-full")
)
with ui.column().classes("col-span-12 lg:col-span-3"): with ui.column().classes("col-span-12 lg:col-span-3"):
ui.element("div") ui.element("div")
@@ -198,10 +203,14 @@ def register_page() -> None: # noqa: PLR0915
if photos: if photos:
current_photo = photos[active_index[0]] current_photo = photos[active_index[0]]
description_input = ui.input( description_input = (
ui.input(
label="Image description", label="Image description",
value=current_photo.description or "", value=current_photo.description or "",
).props("outlined dense").classes("w-full") )
.props("outlined dense")
.classes("w-full")
)
async def save_description() -> None: async def save_description() -> None:
try: try:
+3 -8
View File
@@ -361,9 +361,7 @@ def register_page() -> None: # noqa: PLR0915
return return
resubmittable_count = sum( resubmittable_count = sum(
1 1 for js in job.job_sources if js.status in {JobSourceStatus.FAILED, JobSourceStatus.CANCELLED}
for js in job.job_sources
if js.status in {JobSourceStatus.FAILED, JobSourceStatus.CANCELLED}
) )
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
@@ -374,8 +372,7 @@ def register_page() -> None: # noqa: PLR0915
metadata_row("Current Status:", job.status.value.upper()) metadata_row("Current Status:", job.status.value.upper())
metadata_row("Resubmittable Sources:", str(resubmittable_count)) metadata_row("Resubmittable Sources:", str(resubmittable_count))
ui.label( ui.label(
"Resubmit queues failed and cancelled linked sources. " "Resubmit queues failed and cancelled linked sources. Prior execution evidence remains preserved."
"Prior execution evidence remains preserved."
).classes("text-xs ui-text-muted") ).classes("text-xs ui-text-muted")
async def submit_resubmit() -> None: async def submit_resubmit() -> None:
@@ -440,9 +437,7 @@ def register_page() -> None: # noqa: PLR0915
ui.label( ui.label(
"Related JobSource links, execution attempts, transport responses, and attempt artifacts " "Related JobSource links, execution attempts, transport responses, and attempt artifacts "
"will be removed. Source records and files remain until deleted separately." "will be removed. Source records and files remain until deleted separately."
).classes( ).classes("text-xs ui-text-muted")
"text-xs ui-text-muted"
)
async def submit_delete() -> None: async def submit_delete() -> None:
try: try:
+9 -5
View File
@@ -368,9 +368,9 @@ def register_page() -> None: # noqa: PLR0915
label="", label="",
on_upload=on_photo_selected, on_upload=on_photo_selected,
auto_upload=True, auto_upload=True,
).props( ).props(f'multiple accept="{",".join(sorted(IMAGE_UPLOAD_EXTENSIONS))}"').classes(
f'multiple accept="{",".join(sorted(IMAGE_UPLOAD_EXTENSIONS))}"' "hidden person-photo-upload"
).classes("hidden person-photo-upload") )
ui.button( ui.button(
"Upload Photo(s)", "Upload Photo(s)",
on_click=lambda: ui.run_javascript( on_click=lambda: ui.run_javascript(
@@ -606,14 +606,18 @@ def _render_person_form_fields(
if person is not None if person is not None
else [] else []
) )
tags_input = ui.select( tags_input = (
ui.select(
sorted(tag_options, key=str.casefold), sorted(tag_options, key=str.casefold),
label="Tags", label="Tags",
value=selected_tags, value=selected_tags,
multiple=True, multiple=True,
with_input=True, with_input=True,
new_value_mode="add-unique", new_value_mode="add-unique",
).props("outlined use-chips").classes("w-full ui-form-surface") )
.props("outlined use-chips")
.classes("w-full ui-form-surface")
)
return PersonFormFields( return PersonFormFields(
last_name=last_name_input, last_name=last_name_input,
+4 -14
View File
@@ -651,19 +651,11 @@ def _render_machine_candidates(
evidence_service: EvidenceService, evidence_service: EvidenceService,
) -> None: ) -> None:
successful = [ successful = [
attempt attempt for attempt in attempts if attempt.status == JobSourceStatus.TRANSCRIBED and attempt.raw_transcription
for attempt in attempts
if attempt.status == JobSourceStatus.TRANSCRIBED and attempt.raw_transcription
]
candidates = [
attempt for attempt in successful if attempt.id != source.preferred_execution_attempt_id
] ]
candidates = [attempt for attempt in successful if attempt.id != source.preferred_execution_attempt_id]
preferred_attempt = next( preferred_attempt = next(
( (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
),
None, None,
) )
@@ -697,9 +689,7 @@ def _render_machine_candidates(
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-3"): with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-3"):
with ui.column().classes("gap-1"): with ui.column().classes("gap-1"):
ui.label("Preferred machine transcription").classes("text-xs font-semibold") ui.label("Preferred machine transcription").classes("text-xs font-semibold")
ui.label(source.raw_transcription).classes( ui.label(source.raw_transcription).classes("p-2 ui-note-box text-xs whitespace-pre-wrap")
"p-2 ui-note-box text-xs whitespace-pre-wrap"
)
with ui.column().classes("gap-1"): with ui.column().classes("gap-1"):
ui.label("Candidate transcription").classes("text-xs font-semibold") ui.label("Candidate transcription").classes("text-xs font-semibold")
ui.label(attempt.raw_transcription or "").classes( ui.label(attempt.raw_transcription or "").classes(
+1 -3
View File
@@ -26,9 +26,7 @@ from transcription.services.workflows import advance_job
async def _attempts_for_job(session, job) -> list[ExecutionAttempt]: async def _attempts_for_job(session, job) -> list[ExecutionAttempt]:
"""Load execution attempts for a job; V4.7 moved evidence off JobSource.""" """Load execution attempts for a job; V4.7 moved evidence off JobSource."""
job_source_ids = [job_source.id for job_source in job.job_sources] job_source_ids = [job_source.id for job_source in job.job_sources]
result = await session.exec( result = await session.exec(select(ExecutionAttempt).where(col(ExecutionAttempt.job_source_id).in_(job_source_ids)))
select(ExecutionAttempt).where(col(ExecutionAttempt.job_source_id).in_(job_source_ids))
)
return list(result.all()) return list(result.all())
+1 -5
View File
@@ -154,11 +154,7 @@ class TestJobService:
finally: finally:
event.remove(bind, "before_cursor_execute", capture) event.remove(bind, "before_cursor_execute", capture)
claim_sql = [ claim_sql = [item for item in statements if item.lstrip().upper().startswith(("SELECT", "UPDATE"))]
item
for item in statements
if item.lstrip().upper().startswith(("SELECT", "UPDATE"))
]
assert len(claim_sql) == 1, claim_sql assert len(claim_sql) == 1, claim_sql
assert "LIMIT" in claim_sql[0].upper() assert "LIMIT" in claim_sql[0].upper()
assert "JOIN" not in claim_sql[0].upper() assert "JOIN" not in claim_sql[0].upper()
+5 -2
View File
@@ -7,10 +7,13 @@ from transcription.db.models import Person
from transcription.services.people import PeopleService from transcription.services.people import PeopleService
from transcription.services.photos import PhotosService from transcription.services.photos import PhotosService
PNG_BYTES = bytes.fromhex( PNG_BYTES = (
bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100" "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100"
"05fe02fea7b1b8000000004945" "05fe02fea7b1b8000000004945"
) + b"NDAE\xae\x42\x60\x82" )
+ b"NDAE\xae\x42\x60\x82"
)
@pytest.mark.asyncio @pytest.mark.asyncio
+1 -3
View File
@@ -35,9 +35,7 @@ def test_quality_analysis_accepts_clean_transcription():
@pytest.mark.unit @pytest.mark.unit
def test_quality_warning_payload_is_versioned(): def test_quality_warning_payload_is_versioned():
payload = quality_warning_payload( payload = quality_warning_payload(analyze_transcription_quality("[document body typeset]\nBroken \ufffd"))
analyze_transcription_quality("[document body typeset]\nBroken \ufffd")
)
assert payload["schema_name"] == "transcription.quality-warnings" assert payload["schema_name"] == "transcription.quality-warnings"
assert payload["schema_version"] == "1" assert payload["schema_version"] == "1"
+1 -4
View File
@@ -73,10 +73,7 @@ async def test_create_job_for_document_sorts_sources_and_creates_links(async_ses
assert all(source.filename.endswith(".pdf") for source in sources) assert all(source.filename.endswith(".pdf") for source in sources)
assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources) assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources)
assert all(Path(source.filename).stem == str(source.id) for source in sources) assert all(Path(source.filename).stem == str(source.id) for source in sources)
assert all( assert all(source.file_path == f"documents/{document.id}/{source.filename}" for source in sources)
source.file_path == f"documents/{document.id}/{source.filename}"
for source in sources
)
assert [source.file_hash for source in sources] == [ assert [source.file_hash for source in sources] == [
"ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb",
"3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d", "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d",
+2 -12
View File
@@ -43,13 +43,7 @@ pytestmark = [
def _real_image_paths() -> list[Path]: def _real_image_paths() -> list[Path]:
if not REAL_IMAGES_DIR.exists(): if not REAL_IMAGES_DIR.exists():
return [] return []
return sorted( return sorted([p for p in REAL_IMAGES_DIR.iterdir() if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS])
[
p
for p in REAL_IMAGES_DIR.iterdir()
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
]
)
def _artifact_filename(image_path: Path) -> str: def _artifact_filename(image_path: Path) -> str:
@@ -78,11 +72,7 @@ class TestRealImageExternalTranscription:
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
artifact_path = ARTIFACTS_DIR / _artifact_filename(image_path) artifact_path = ARTIFACTS_DIR / _artifact_filename(image_path)
artifact_text = ( artifact_text = (
f"source: {image_path.name}\n" f"source: {image_path.name}\nprovider: {result.provider}\nmodel: {result.model}\n---\n{result.text}\n"
f"provider: {result.provider}\n"
f"model: {result.model}\n"
"---\n"
f"{result.text}\n"
) )
artifact_path.write_text(artifact_text, encoding="utf-8") artifact_path.write_text(artifact_text, encoding="utf-8")
assert artifact_path.exists() assert artifact_path.exists()
+3 -12
View File
@@ -107,15 +107,12 @@ class TestWorkflowReliability:
async with services.jobs._session_scope() as session: async with services.jobs._session_scope() as session:
attempts = ( attempts = (
(
await session.exec( await session.exec(
select(ExecutionAttempt).where( select(ExecutionAttempt).where(
col(ExecutionAttempt.job_source_id).in_([js.id for js in result.job_sources]) col(ExecutionAttempt.job_source_id).in_([js.id for js in result.job_sources])
) )
) )
) ).all()
.all()
)
error_detail = next(attempt.error_detail for attempt in attempts if attempt.error_detail is not None) error_detail = next(attempt.error_detail for attempt in attempts if attempt.error_detail is not None)
assert "timed out" in error_detail.lower() assert "timed out" in error_detail.lower()
assert "20.0s" in error_detail assert "20.0s" in error_detail
@@ -179,15 +176,12 @@ class TestWorkflowReliability:
async with services.jobs._session_scope() as session: async with services.jobs._session_scope() as session:
attempts = ( attempts = (
(
await session.exec( await session.exec(
select(ExecutionAttempt).where( select(ExecutionAttempt).where(
col(ExecutionAttempt.job_source_id).in_([js.id for js in result.job_sources]) col(ExecutionAttempt.job_source_id).in_([js.id for js in result.job_sources])
) )
) )
) ).all()
.all()
)
assert len(attempts) == 1 assert len(attempts) == 1
duration_ms = attempts[0].duration_ms duration_ms = attempts[0].duration_ms
@@ -242,15 +236,12 @@ class TestWorkflowReliability:
async with services.jobs._session_scope() as session: async with services.jobs._session_scope() as session:
attempts = ( attempts = (
(
await session.exec( await session.exec(
select(ExecutionAttempt).where( select(ExecutionAttempt).where(
col(ExecutionAttempt.job_source_id).in_([js.id for js in result.job_sources]) col(ExecutionAttempt.job_source_id).in_([js.id for js in result.job_sources])
) )
) )
) ).all()
.all()
)
assert len(attempts) == 1 assert len(attempts) == 1
attempt = attempts[0] attempt = attempts[0]
timing = (attempt.normalized_metadata or {}).get("processing_timing") timing = (attempt.normalized_metadata or {}).get("processing_timing")
+11 -14
View File
@@ -163,16 +163,13 @@ async def test_create_all_declares_hot_path_indexes(tmp_path):
) )
} }
job_source_unique = [ job_source_unique = [
constraint["column_names"] constraint["column_names"] for constraint in database.get_unique_constraints("job_source")
for constraint in database.get_unique_constraints("job_source")
] ]
document_tag_unique = [ document_tag_unique = [
constraint["column_names"] constraint["column_names"] for constraint in database.get_unique_constraints("document_tag")
for constraint in database.get_unique_constraints("document_tag")
] ]
person_tag_unique = [ person_tag_unique = [
constraint["column_names"] constraint["column_names"] for constraint in database.get_unique_constraints("person_tag")
for constraint in database.get_unique_constraints("person_tag")
] ]
return indexes, job_source_unique, document_tag_unique, person_tag_unique return indexes, job_source_unique, document_tag_unique, person_tag_unique
@@ -252,11 +249,11 @@ async def test_reconcile_legacy_job_source_columns_drops_executed_at(tmp_path):
await connection.execute( await connection.execute(
text( text(
'create table "job_source" (' 'create table "job_source" ('
'id char(32) not null primary key, ' "id char(32) not null primary key, "
'job_id char(32) not null, ' "job_id char(32) not null, "
'source_id char(32) not null, ' "source_id char(32) not null, "
'status varchar(11) not null, ' "status varchar(11) not null, "
'executed_at datetime not null, ' "executed_at datetime not null, "
'constraint "uq_job_source_job_source" unique ("job_id", "source_id"), ' 'constraint "uq_job_source_job_source" unique ("job_id", "source_id"), '
'foreign key("job_id") references "job" ("id"), ' 'foreign key("job_id") references "job" ("id"), '
'foreign key("source_id") references "source" ("id")' 'foreign key("source_id") references "source" ("id")'
@@ -291,14 +288,14 @@ async def test_reconcile_canonical_media_paths_normalizes_source_and_photo_paths
await connection.execute( await connection.execute(
text( text(
'insert into "person" (id, given_names, last_name, created_at, updated_at) ' 'insert into "person" (id, given_names, last_name, created_at, updated_at) '
'values (:id, :given_names, :last_name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)' "values (:id, :given_names, :last_name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
), ),
{"id": "11" * 16, "given_names": "Portrait", "last_name": "Person"}, {"id": "11" * 16, "given_names": "Portrait", "last_name": "Person"},
) )
await connection.execute( await connection.execute(
text( text(
'insert into "photo" (id, person_id, path, is_primary, created_at, updated_at) ' 'insert into "photo" (id, person_id, path, is_primary, created_at, updated_at) '
'values (:id, :person_id, :path, :is_primary, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)' "values (:id, :person_id, :path, :is_primary, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
), ),
{ {
"id": "44" * 16, "id": "44" * 16,
@@ -310,7 +307,7 @@ async def test_reconcile_canonical_media_paths_normalizes_source_and_photo_paths
await connection.execute( await connection.execute(
text( text(
'insert into "document" (id, name, created_at, updated_at) ' 'insert into "document" (id, name, created_at, updated_at) '
'values (:id, :name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)' "values (:id, :name, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
), ),
{"id": "22" * 16, "name": "Doc"}, {"id": "22" * 16, "name": "Doc"},
) )
+1 -4
View File
@@ -109,10 +109,7 @@ def _declared_env_example_keys(*, include_commented: bool) -> set[str]:
def _active_env_example_values() -> dict[str, str]: def _active_env_example_values() -> dict[str, str]:
text = _read(".env.example") text = _read(".env.example")
return { return {key: value.strip() for key, value in re.findall(r"^\s*([A-Z0-9_]+)\s*=\s*(.*)$", text, flags=re.MULTILINE)}
key: value.strip()
for key, value in re.findall(r"^\s*([A-Z0-9_]+)\s*=\s*(.*)$", text, flags=re.MULTILINE)
}
def _normalize_env_path_value(value: str | None) -> str | None: def _normalize_env_path_value(value: str | None) -> str | None:
+1 -3
View File
@@ -57,9 +57,7 @@ def _source_files() -> list[Path]:
def _is_registered_with_framework(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> bool: def _is_registered_with_framework(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> bool:
return any( return any(ast.unparse(decorator).startswith(REGISTRATION_DECORATOR_PREFIXES) for decorator in node.decorator_list)
ast.unparse(decorator).startswith(REGISTRATION_DECORATOR_PREFIXES) for decorator in node.decorator_list
)
def _public_definitions() -> dict[str, str]: def _public_definitions() -> dict[str, str]:
+7 -4
View File
@@ -36,7 +36,12 @@ async def _document_ids(settings: Settings) -> set[str]:
async def _source_counts_by_document(settings: Settings) -> dict[str, int]: async def _source_counts_by_document(settings: Settings) -> dict[str, int]:
async with session_scope(settings=settings) as session: async with session_scope(settings=settings) as session:
rows = await session.exec( rows = await session.exec(
select(Source.document_id, func.count(Source.id)).group_by(Source.document_id) 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} return {str(document_id): int(count) for document_id, count in rows}
@@ -93,9 +98,7 @@ async def assert_storage_reconciliation(*, upload_dir: Path, settings: Settings)
if mismatches: if mismatches:
report = "\n".join(f"- {item}" for item in mismatches) report = "\n".join(f"- {item}" for item in mismatches)
raise AssertionError( raise AssertionError(
"Storage reconciliation mismatch(es) detected.\n" f"Storage reconciliation mismatch(es) detected.\nReconciling item count: {len(mismatches)}\n{report}"
f"Reconciling item count: {len(mismatches)}\n"
f"{report}"
) )
+2 -8
View File
@@ -129,11 +129,7 @@ def _notifies_negative(tree: ast.Module) -> bool:
): ):
continue continue
for keyword in node.keywords: for keyword in node.keywords:
if ( if keyword.arg == "type" and isinstance(keyword.value, ast.Constant) and keyword.value.value == "negative":
keyword.arg == "type"
and isinstance(keyword.value, ast.Constant)
and keyword.value.value == "negative"
):
return True return True
return False return False
@@ -149,6 +145,4 @@ def test_no_page_hand_rolls_error_notifications():
offenders = sorted( offenders = sorted(
path.stem for path in _page_paths() if _notifies_negative(ast.parse(path.read_text(encoding="utf-8"))) path.stem for path in _page_paths() if _notifies_negative(ast.parse(path.read_text(encoding="utf-8")))
) )
assert offenders == [], ( assert offenders == [], f"Pages must render errors via error_presenter.show_error, not ui.notify: {offenders}"
f"Pages must render errors via error_presenter.show_error, not ui.notify: {offenders}"
)
+2 -2
View File
@@ -153,7 +153,7 @@ def test_export_import_migration_backfills_legacy_portraits_and_homepage_images(
connection.execute( connection.execute(
text( text(
'insert into "person" (id, full_name, portrait_path, created_at, updated_at) ' 'insert into "person" (id, full_name, portrait_path, created_at, updated_at) '
'values (:id, :full_name, :portrait_path, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)' "values (:id, :full_name, :portrait_path, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
), ),
{"id": person_id, "full_name": "Legacy Portrait", "portrait_path": "persons/legacy/portrait.png"}, {"id": person_id, "full_name": "Legacy Portrait", "portrait_path": "persons/legacy/portrait.png"},
) )
@@ -264,7 +264,7 @@ def test_migration_backfills_legacy_media_when_photo_table_contains_stale_rows(t
connection.execute( connection.execute(
text( text(
'insert into "person" (id, full_name, portrait_path, created_at, updated_at) ' 'insert into "person" (id, full_name, portrait_path, created_at, updated_at) '
'values (:id, :full_name, :portrait_path, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)' "values (:id, :full_name, :portrait_path, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
), ),
{"id": person_id, "full_name": "Legacy Portrait", "portrait_path": "persons/legacy/portrait.png"}, {"id": person_id, "full_name": "Legacy Portrait", "portrait_path": "persons/legacy/portrait.png"},
) )
+1 -2
View File
@@ -56,8 +56,7 @@ def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[Fast
expected_path = Path(database.path).resolve() expected_path = Path(database.path).resolve()
if runtime_path != expected_path: if runtime_path != expected_path:
raise RuntimeError( raise RuntimeError(
"Refusing to initialize destructive UI fixtures against " f"Refusing to initialize destructive UI fixtures against {runtime_path}; expected {expected_path}"
f"{runtime_path}; expected {expected_path}"
) )
yield app, client yield app, client
+2 -6
View File
@@ -30,12 +30,8 @@ def test_person_selector_label_disambiguates_without_changing_identity():
def test_family_search_url_uses_fixed_person_details_route(): def test_family_search_url_uses_fixed_person_details_route():
assert family_search_url("G8T4-MDQ") == ( assert family_search_url("G8T4-MDQ") == ("https://www.familysearch.org/tree/person/details/G8T4-MDQ")
"https://www.familysearch.org/tree/person/details/G8T4-MDQ"
)
def test_google_maps_search_url_encodes_place_query(): def test_google_maps_search_url_encodes_place_query():
assert google_maps_search_url("New York, NY") == ( assert google_maps_search_url("New York, NY") == ("https://www.google.com/maps/search/?api=1&query=New+York%2C+NY")
"https://www.google.com/maps/search/?api=1&query=New+York%2C+NY"
)
+1 -3
View File
@@ -184,9 +184,7 @@ class TestJobsPageRendering:
assert "Delete is blocked while the job is processing." in response.text assert "Delete is blocked while the job is processing." in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_job_delete_page_allows_deletion_for_queued_job( async def test_job_delete_page_allows_deletion_for_queued_job(self, app_client, seed_document_with_unlinked_job):
self, app_client, seed_document_with_unlinked_job
):
_, client = app_client _, client = app_client
_, job_id = seed_document_with_unlinked_job _, job_id = seed_document_with_unlinked_job
+1 -1
View File
@@ -31,7 +31,7 @@ class TestPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Home" in response.text assert "Home" in response.text
assert "Edit Home Page" in response.text assert "Edit Home Page" in response.text
assert '/homepage' in response.text assert "/homepage" in response.text
def test_homepage_edit_page_renders(self, app_client): def test_homepage_edit_page_renders(self, app_client):
"""GET /ui/homepage/edit renders the edit page.""" """GET /ui/homepage/edit renders the edit page."""
+1 -3
View File
@@ -132,9 +132,7 @@ def _preflight(connection: Connection, *, strict: bool) -> None:
def rotate_stored_images(connection: Connection, *, dry_run: bool) -> int: def rotate_stored_images(connection: Connection, *, dry_run: bool) -> int:
"""Step 1: rewrite every mis-oriented stored image and its recorded digest.""" """Step 1: rewrite every mis-oriented stored image and its recorded digest."""
source = SQLModel.metadata.tables["source"] source = SQLModel.metadata.tables["source"]
rows = connection.execute( rows = connection.execute(select(source.c.id, source.c.file_path, source.c.filename)).all()
select(source.c.id, source.c.file_path, source.c.filename)
).all()
rotated = 0 rotated = 0
missing = 0 missing = 0
+4 -15
View File
@@ -55,10 +55,7 @@ def _uniqueness_already_enforced(connection: Connection) -> bool:
if any(constraint.get("name") == UNIQUE_NAME for constraint in unique_constraints): if any(constraint.get("name") == UNIQUE_NAME for constraint in unique_constraints):
return True return True
indexes = inspector.get_indexes("job_source") indexes = inspector.get_indexes("job_source")
return any( return any(index.get("name") == UNIQUE_NAME and index.get("unique") is True for index in indexes)
index.get("name") == UNIQUE_NAME and index.get("unique") is True
for index in indexes
)
def _choose_keeper( def _choose_keeper(
@@ -100,11 +97,7 @@ def deduplicate_job_source_membership(connection: Connection, *, dry_run: bool)
job_source.c.source_id, job_source.c.source_id,
func.max(execution_attempt.c.created_at).label("latest_attempt_at"), func.max(execution_attempt.c.created_at).label("latest_attempt_at"),
) )
.select_from( .select_from(job_source.outerjoin(execution_attempt, execution_attempt.c.job_source_id == job_source.c.id))
job_source.outerjoin(
execution_attempt, execution_attempt.c.job_source_id == job_source.c.id
)
)
.group_by(job_source.c.id, job_source.c.job_id, job_source.c.source_id) .group_by(job_source.c.id, job_source.c.job_id, job_source.c.source_id)
) )
.mappings() .mappings()
@@ -150,13 +143,9 @@ def add_job_source_uniqueness(connection: Connection, *, dry_run: bool) -> None:
dialect = connection.dialect.name dialect = connection.dialect.name
if dialect == "postgresql": if dialect == "postgresql":
statement = text( statement = text(f'alter table "job_source" add constraint "{UNIQUE_NAME}" unique ("job_id", "source_id")')
f'alter table "job_source" add constraint "{UNIQUE_NAME}" unique ("job_id", "source_id")'
)
else: else:
statement = text( statement = text(f'create unique index "{UNIQUE_NAME}" on "job_source" ("job_id", "source_id")')
f'create unique index "{UNIQUE_NAME}" on "job_source" ("job_id", "source_id")'
)
print(f" applying {UNIQUE_NAME}") print(f" applying {UNIQUE_NAME}")
if not dry_run: if not dry_run:
connection.execute(statement) connection.execute(statement)