generated from john/python-template
Compare commits
4
Commits
4aaa9bd581
...
736d0c06f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736d0c06f4 | ||
|
|
26f9c83f54 | ||
|
|
a2bb1acd6b | ||
|
|
2a56365847 |
+11
-5
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 = (
|
||||||
select(source_table.c["id"], source_table.c["portrait_path"]).where(
|
connection.execute(
|
||||||
source_table.c["portrait_path"].is_not(None)
|
select(source_table.c["id"], source_table.c["portrait_path"]).where(
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -67,9 +67,13 @@ 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 = (
|
||||||
filename=filename,
|
Path(filename).name
|
||||||
filename_stem=filename_stem,
|
if preserve_original_name
|
||||||
|
else build_stored_filename(
|
||||||
|
filename=filename,
|
||||||
|
filename_stem=filename_stem,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return await write_media_bytes(
|
return await write_media_bytes(
|
||||||
target_dir=target_dir,
|
target_dir=target_dir,
|
||||||
|
|||||||
@@ -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 []
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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 = (
|
||||||
sorted(tag_options, key=str.casefold),
|
ui.select(
|
||||||
label="Tags",
|
sorted(tag_options, key=str.casefold),
|
||||||
value=selected_tags,
|
label="Tags",
|
||||||
multiple=True,
|
value=selected_tags,
|
||||||
with_input=True,
|
multiple=True,
|
||||||
new_value_mode="add-unique",
|
with_input=True,
|
||||||
).props("outlined use-chips").classes("w-full ui-form-surface")
|
new_value_mode="add-unique",
|
||||||
|
)
|
||||||
|
.props("outlined use-chips")
|
||||||
|
.classes("w-full ui-form-surface")
|
||||||
|
)
|
||||||
|
|
||||||
linked_people.render()
|
linked_people.render()
|
||||||
|
|
||||||
|
|||||||
@@ -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] = (
|
||||||
label="Homepage markdown",
|
ui.textarea(
|
||||||
value=read_homepage_markdown(),
|
label="Homepage markdown",
|
||||||
).props("outlined autogrow").classes("w-full")
|
value=read_homepage_markdown(),
|
||||||
|
)
|
||||||
|
.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 = (
|
||||||
label="Image description",
|
ui.input(
|
||||||
value=current_photo.description or "",
|
label="Image description",
|
||||||
).props("outlined dense").classes("w-full")
|
value=current_photo.description or "",
|
||||||
|
)
|
||||||
|
.props("outlined dense")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
|
||||||
async def save_description() -> None:
|
async def save_description() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = (
|
||||||
sorted(tag_options, key=str.casefold),
|
ui.select(
|
||||||
label="Tags",
|
sorted(tag_options, key=str.casefold),
|
||||||
value=selected_tags,
|
label="Tags",
|
||||||
multiple=True,
|
value=selected_tags,
|
||||||
with_input=True,
|
multiple=True,
|
||||||
new_value_mode="add-unique",
|
with_input=True,
|
||||||
).props("outlined use-chips").classes("w-full ui-form-surface")
|
new_value_mode="add-unique",
|
||||||
|
)
|
||||||
|
.props("outlined use-chips")
|
||||||
|
.classes("w-full ui-form-surface")
|
||||||
|
)
|
||||||
|
|
||||||
return PersonFormFields(
|
return PersonFormFields(
|
||||||
last_name=last_name_input,
|
last_name=last_name_input,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = (
|
||||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100"
|
bytes.fromhex(
|
||||||
"05fe02fea7b1b8000000004945"
|
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100"
|
||||||
) + b"NDAE\xae\x42\x60\x82"
|
"05fe02fea7b1b8000000004945"
|
||||||
|
)
|
||||||
|
+ b"NDAE\xae\x42\x60\x82"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user