fix: resolve remaining ruff lint and mypy type errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Visser 2026-02-26 22:58:55 +01:00
parent bf04c77aa9
commit ac002131d6
24 changed files with 219 additions and 57 deletions

View file

@ -11,6 +11,7 @@ from app.api.errors import ApiException
from app.db.models import User
from app.db.session import get_db_session
from app.services.domains.scholars import application as scholar_service
from app.services.domains.scholars import uploads as scholar_uploads
from app.settings import settings
router = APIRouter(tags=["media"])
@ -41,7 +42,7 @@ async def get_uploaded_scholar_image(
)
try:
image_path = scholar_service.resolve_upload_file_path(
image_path = scholar_uploads.resolve_upload_file_path(
upload_dir=settings.scholar_image_upload_dir,
relative_path=profile.profile_image_upload_path,
)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from typing import NoReturn
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
@ -61,7 +62,7 @@ def _serialize_user_payload(user: User) -> dict[str, object]:
}
def _raise_invalid_credentials(*, normalized_email: str) -> None:
def _raise_invalid_credentials(*, normalized_email: str) -> NoReturn:
structured_log(logger, "info", "api.auth.login_failed", email=normalized_email)
raise ApiException(
status_code=401,

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import logging
import re
from typing import Any
@ -32,6 +33,7 @@ from app.services.domains.settings import application as user_settings_service
from app.settings import settings
logger = logging.getLogger(__name__)
_background_tasks: set[asyncio.Task[Any]] = set()
router = APIRouter(prefix="/runs", tags=["api-runs"])
ACTIVE_RUN_STATUSES = {RunStatus.RUNNING, RunStatus.RESOLVING}
@ -656,11 +658,9 @@ async def run_manual(
await db_session.commit()
# Kick off background execution
import asyncio
from app.db.session import get_session_factory
asyncio.create_task(
task = asyncio.create_task(
ingest_service.execute_run(
session_factory=get_session_factory(),
run_id=run.id,
@ -680,6 +680,8 @@ async def run_manual(
idempotency_key=idempotency_key,
)
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return success_payload(
request,

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
@ -29,6 +30,7 @@ from app.services.domains.portability import application as import_export_servic
from app.services.domains.scholar import rate_limit as scholar_rate_limit
from app.services.domains.scholar.source import ScholarSource
from app.services.domains.scholars import application as scholar_service
from app.services.domains.scholars import search_hints as scholar_search_hints
from app.settings import settings
logger = logging.getLogger(__name__)
@ -110,7 +112,7 @@ def _serialize_scholar(profile) -> dict[str, object]:
if profile.profile_image_upload_path:
uploaded_image_url = _uploaded_image_media_path(int(profile.id))
profile_image_url, profile_image_source = scholar_service.resolve_profile_image(
profile_image_url, profile_image_source = scholar_search_hints.resolve_profile_image(
profile,
uploaded_image_url=uploaded_image_url,
)
@ -180,7 +182,7 @@ async def _hydrate_scholar_metadata_if_needed(
return profile
def _search_kwargs() -> dict[str, object]:
def _search_kwargs() -> dict[str, Any]:
return {
"network_error_retries": settings.ingestion_network_error_retries,
"retry_backoff_seconds": settings.ingestion_retry_backoff_seconds,
@ -202,7 +204,7 @@ def _search_response_data(query: str, parsed) -> dict[str, object]:
"query": query.strip(),
"state": parsed.state.value,
"state_reason": parsed.state_reason,
"action_hint": scholar_service.scrape_state_hint(
"action_hint": scholar_search_hints.scrape_state_hint(
state=parsed.state,
state_reason=parsed.state_reason,
),

View file

@ -54,7 +54,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
if request_token is None and self._is_form_payload(request):
body = await request.body()
request_token = self._token_from_form_body(request, body)
request._receive = self._build_receive(body) # type: ignore[attr-defined]
request._receive = self._build_receive(body)
if not request_token or not compare_digest(str(session_token), str(request_token)):
structured_log(

View file

@ -68,8 +68,7 @@ async def _run_serialized_fetch(
source_path: str,
) -> tuple[httpx.Response, bool]:
session_factory = get_session_factory()
async with session_factory() as db_session:
async with db_session.begin():
async with session_factory() as db_session, db_session.begin():
await _acquire_arxiv_lock(db_session)
runtime_state = await _load_runtime_state_for_update(db_session)
wait_seconds = await _wait_for_allowed_slot_or_raise(

View file

@ -109,6 +109,9 @@ def _classify_failure_bucket(*, state: str, state_reason: str) -> str:
return FAILURE_BUCKET_OTHER
_background_tasks: set[asyncio.Task[Any]] = set()
class ScholarIngestionService:
def __init__(self, *, source: ScholarSource) -> None:
self._source = source
@ -287,8 +290,7 @@ class ScholarIngestionService:
paged_parse_result: PagedParseResult,
) -> None:
parsed_page = paged_parse_result.parsed_page
if parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS}:
if any(code.startswith("layout_") for code in parsed_page.warnings):
if parsed_page.state in {ParseState.OK, ParseState.NO_RESULTS} and any(code.startswith("layout_") for code in parsed_page.warnings):
raise RuntimeError(f"Layout warning marked as terminal for scholar_id={scholar_id}.")
for publication in paged_parse_result.publications:
if not publication.title.strip():
@ -1490,7 +1492,7 @@ class ScholarIngestionService:
# Fire-and-forget enrichment in a separate background task
if intended_final_status not in (RunStatus.CANCELED,):
asyncio.create_task(
task = asyncio.create_task(
self._background_enrich(
session_factory,
run_id=run.id,
@ -1498,6 +1500,8 @@ class ScholarIngestionService:
openalex_api_key=getattr(user_settings, "openalex_api_key", None),
)
)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
except Exception as exc:
await db_session.rollback()
logger.exception("ingestion.background_run_failed", extra={"run_id": run_id, "user_id": user_id})
@ -2807,7 +2811,7 @@ class ScholarIngestionService:
publication.venue_text = candidate.venue_text
if candidate.title_url:
publication.pub_url = build_publication_url(candidate.title_url)
local_doi = first_doi_from_texts(candidate.title_url, candidate.venue_text, candidate.title)
first_doi_from_texts(candidate.title_url, candidate.venue_text, candidate.title)
async def _resolve_publication(
self,

View file

@ -367,10 +367,7 @@ def _publication_identity(pub: PublicationCandidate) -> str:
def _is_fuzzy_dup(tokens: set[str], seen: list[set[str]]) -> bool:
for existing in seen:
if _jaccard(tokens, existing) >= _CANONICAL_DEDUP_THRESHOLD:
return True
return False
return any(_jaccard(tokens, existing) >= _CANONICAL_DEDUP_THRESHOLD for existing in seen)
def _build_body_excerpt(body: str, *, max_chars: int = 220) -> str | None:

View file

@ -330,9 +330,7 @@ class SchedulerService:
await session.commit()
if queue_item is None:
return False
if queue_item.status == QueueItemStatus.DROPPED.value:
return False
return True
return queue_item.status != QueueItemStatus.DROPPED.value
async def _queue_job_has_available_scholar(
self,

View file

@ -93,8 +93,7 @@ def find_best_match(
for original_score, cand in top_scored_candidates:
tb_score = 0
if target_year is not None and cand.publication_year is not None:
if abs(target_year - cand.publication_year) <= 1:
if target_year is not None and cand.publication_year is not None and abs(target_year - cand.publication_year) <= 1:
tb_score += 1
candidate_author_names = [a.display_name for a in cand.authors if a.display_name]

View file

@ -1 +1,21 @@
from app.services.domains.portability.application import *
from app.services.domains.portability.application import (
EXPORT_SCHEMA_VERSION as EXPORT_SCHEMA_VERSION,
)
from app.services.domains.portability.application import (
MAX_IMPORT_PUBLICATIONS as MAX_IMPORT_PUBLICATIONS,
)
from app.services.domains.portability.application import (
MAX_IMPORT_SCHOLARS as MAX_IMPORT_SCHOLARS,
)
from app.services.domains.portability.application import (
ImportedPublicationInput as ImportedPublicationInput,
)
from app.services.domains.portability.application import (
ImportExportError as ImportExportError,
)
from app.services.domains.portability.application import (
export_user_data as export_user_data,
)
from app.services.domains.portability.application import (
import_user_data as import_user_data,
)

View file

@ -188,7 +188,7 @@ async def _discover_crossref_doi(
discovered_doi = await crossref_service.discover_doi_for_publication(item=item)
normalized_doi = normalize_doi(discovered_doi)
if normalized_doi is None:
if discovered_doi is None or normalized_doi is None:
return False
candidate = _candidate(
IdentifierKind.DOI,
@ -216,7 +216,7 @@ async def _discover_arxiv_identifier(
discovered_arxiv = await arxiv_service.discover_arxiv_id_for_publication(item=item)
normalized_arxiv = normalize_arxiv_id(discovered_arxiv)
if normalized_arxiv is None:
if discovered_arxiv is None or normalized_arxiv is None:
return
candidate = _candidate(
IdentifierKind.ARXIV,

View file

@ -1 +1,84 @@
from app.services.domains.publications.application import *
from app.services.domains.publications.application import (
MODE_ALL as MODE_ALL,
)
from app.services.domains.publications.application import (
MODE_LATEST as MODE_LATEST,
)
from app.services.domains.publications.application import (
MODE_NEW as MODE_NEW,
)
from app.services.domains.publications.application import (
MODE_UNREAD as MODE_UNREAD,
)
from app.services.domains.publications.application import (
PublicationListItem as PublicationListItem,
)
from app.services.domains.publications.application import (
UnreadPublicationItem as UnreadPublicationItem,
)
from app.services.domains.publications.application import (
count_favorite_for_user as count_favorite_for_user,
)
from app.services.domains.publications.application import (
count_for_user as count_for_user,
)
from app.services.domains.publications.application import (
count_latest_for_user as count_latest_for_user,
)
from app.services.domains.publications.application import (
count_pdf_queue_items as count_pdf_queue_items,
)
from app.services.domains.publications.application import (
count_unread_for_user as count_unread_for_user,
)
from app.services.domains.publications.application import (
enqueue_all_missing_pdf_jobs as enqueue_all_missing_pdf_jobs,
)
from app.services.domains.publications.application import (
enqueue_retry_pdf_job_for_publication_id as enqueue_retry_pdf_job_for_publication_id,
)
from app.services.domains.publications.application import (
get_latest_run_id_for_user as get_latest_run_id_for_user,
)
from app.services.domains.publications.application import (
get_publication_item_for_user as get_publication_item_for_user,
)
from app.services.domains.publications.application import (
hydrate_pdf_enrichment_state as hydrate_pdf_enrichment_state,
)
from app.services.domains.publications.application import (
list_for_user as list_for_user,
)
from app.services.domains.publications.application import (
list_pdf_queue_items as list_pdf_queue_items,
)
from app.services.domains.publications.application import (
list_pdf_queue_page as list_pdf_queue_page,
)
from app.services.domains.publications.application import (
list_unread_for_user as list_unread_for_user,
)
from app.services.domains.publications.application import (
mark_all_unread_as_read_for_user as mark_all_unread_as_read_for_user,
)
from app.services.domains.publications.application import (
mark_selected_as_read_for_user as mark_selected_as_read_for_user,
)
from app.services.domains.publications.application import (
publications_query as publications_query,
)
from app.services.domains.publications.application import (
resolve_publication_view_mode as resolve_publication_view_mode,
)
from app.services.domains.publications.application import (
retry_pdf_for_user as retry_pdf_for_user,
)
from app.services.domains.publications.application import (
schedule_missing_pdf_enrichment_for_user as schedule_missing_pdf_enrichment_for_user,
)
from app.services.domains.publications.application import (
schedule_retry_pdf_enrichment_for_row as schedule_retry_pdf_enrichment_for_row,
)
from app.services.domains.publications.application import (
set_publication_favorite_for_user as set_publication_favorite_for_user,
)

View file

@ -190,12 +190,10 @@ def _can_enqueue_job(
return False
if int(job.attempt_count) >= _auto_retry_max_attempts():
return False
if _cooldown_active(
return not _cooldown_active(
last_attempt_at=job.last_attempt_at,
attempt_count=int(job.attempt_count),
):
return False
return True
)
def _event_row(

View file

@ -5,7 +5,7 @@ from dataclasses import dataclass
from typing import Any
from app.logging_utils import structured_log
from app.services.domains.arxiv.application import ArxivRateLimitError
from app.services.domains.arxiv.errors import ArxivRateLimitError
from app.services.domains.arxiv.guards import arxiv_skip_reason_for_item
from app.services.domains.openalex.client import OpenAlexBudgetExhaustedError
from app.services.domains.publications.types import PublicationListItem

View file

@ -19,7 +19,7 @@ from app.services.domains.publications.types import PublicationListItem, UnreadP
def _normalized_citation_count(value: object) -> int:
try:
return int(value or 0)
return int(value or 0) # type: ignore[call-overload] # intentionally accepts any object
except (TypeError, ValueError):
return 0

View file

@ -1 +1,51 @@
from app.services.domains.runs.application import *
from app.services.domains.runs.application import (
QUEUE_STATUS_DROPPED as QUEUE_STATUS_DROPPED,
)
from app.services.domains.runs.application import (
QUEUE_STATUS_QUEUED as QUEUE_STATUS_QUEUED,
)
from app.services.domains.runs.application import (
QUEUE_STATUS_RETRYING as QUEUE_STATUS_RETRYING,
)
from app.services.domains.runs.application import (
QueueClearResult as QueueClearResult,
)
from app.services.domains.runs.application import (
QueueListItem as QueueListItem,
)
from app.services.domains.runs.application import (
QueueTransitionError as QueueTransitionError,
)
from app.services.domains.runs.application import (
clear_queue_item_for_user as clear_queue_item_for_user,
)
from app.services.domains.runs.application import (
drop_queue_item_for_user as drop_queue_item_for_user,
)
from app.services.domains.runs.application import (
extract_run_summary as extract_run_summary,
)
from app.services.domains.runs.application import (
get_manual_run_by_idempotency_key as get_manual_run_by_idempotency_key,
)
from app.services.domains.runs.application import (
get_queue_item_for_user as get_queue_item_for_user,
)
from app.services.domains.runs.application import (
get_run_for_user as get_run_for_user,
)
from app.services.domains.runs.application import (
list_queue_items_for_user as list_queue_items_for_user,
)
from app.services.domains.runs.application import (
list_recent_runs_for_user as list_recent_runs_for_user,
)
from app.services.domains.runs.application import (
list_runs_for_user as list_runs_for_user,
)
from app.services.domains.runs.application import (
queue_status_counts_for_user as queue_status_counts_for_user,
)
from app.services.domains.runs.application import (
retry_queue_item_for_user as retry_queue_item_for_user,
)

View file

@ -1,11 +1,11 @@
from __future__ import annotations
from typing import Any
from typing import Any, cast
def _safe_int(value: object, default: int = 0) -> int:
try:
return int(value)
return int(cast(Any, value))
except (TypeError, ValueError):
return default

View file

@ -12,6 +12,15 @@ from app.services.domains.scholar.parser_types import (
ScholarDomInvariantError,
ScholarMalformedDataError,
)
from app.services.domains.scholar.parser_types import (
ParseState as ParseState,
)
from app.services.domains.scholar.parser_types import (
ScholarParserError as ScholarParserError,
)
from app.services.domains.scholar.parser_types import (
ScholarSearchCandidate as ScholarSearchCandidate,
)
from app.services.domains.scholar.parser_utils import (
strip_tags,
)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import re
from html.parser import HTMLParser
from typing import Any
from urllib.parse import parse_qs, urlparse
from app.services.domains.scholar.parser_constants import (
@ -30,7 +31,7 @@ class ScholarRowParser(HTMLParser):
self._title_depth = 0
self._citation_depth = 0
self._year_depth = 0
self._gray_stack: list[dict[str, object]] = []
self._gray_stack: list[dict[str, Any]] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if self._title_depth > 0:
@ -252,9 +253,7 @@ def has_show_more_button(html: str) -> bool:
return False
if 'aria-disabled="true"' in button_tag or "aria-disabled='true'" in button_tag:
return False
if "gs_dis" in button_tag:
return False
return True
return "gs_dis" not in button_tag
def has_operation_error_banner(html: str) -> bool:

View file

@ -6,6 +6,7 @@ import os
import random
from dataclasses import replace
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, func, select, text
@ -182,11 +183,11 @@ def _deserialize_candidate_payload(value: object) -> dict[str, object] | None:
}
def _deserialize_candidates(payload: dict[str, object]) -> list[dict[str, object]]:
def _deserialize_candidates(payload: dict[str, object]) -> list[dict[str, Any]]:
candidates_payload = payload.get("candidates")
if not isinstance(candidates_payload, list):
return []
normalized: list[dict[str, object]] = []
normalized: list[dict[str, Any]] = []
for value in candidates_payload:
candidate = _deserialize_candidate_payload(value)
if candidate is not None:
@ -790,7 +791,7 @@ async def _perform_live_author_search(
cache_ttl_seconds: int,
cache_max_entries: int,
) -> tuple[ParsedAuthorSearchPage, bool]:
runtime_state_updated = await _wait_for_author_search_throttle(
await _wait_for_author_search_throttle(
runtime_state=runtime_state,
normalized_query=normalized_query,
now_utc=datetime.now(UTC),

View file

@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import UserSetting
from app.settings import settings as app_settings
class UserSettingsServiceError(ValueError):
@ -103,9 +104,6 @@ def parse_nav_visible_pages(value: object) -> list[str]:
return deduped
from app.settings import settings as app_settings
async def get_or_create_settings(
db_session: AsyncSession,
*,

View file

@ -65,8 +65,9 @@ def _extract_explicit_doi(text: str | None) -> str | None:
def _publication_doi(item: PublicationListItem | UnreadPublicationItem) -> str | None:
stored = None
if getattr(item, "display_identifier", None) and item.display_identifier.kind == "doi":
stored = normalize_doi(item.display_identifier.value)
di = getattr(item, "display_identifier", None)
if di is not None and di.kind == "doi":
stored = normalize_doi(di.value)
explicit_doi = _extract_explicit_doi(item.pub_url) or _extract_explicit_doi(item.venue_text)
if explicit_doi:

View file

@ -22,7 +22,7 @@ def test_parse_run_interval_minutes_accepts_valid_value() -> None:
def test_parse_run_interval_minutes_rejects_below_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Check interval must be at least 15 minutes.",
match=r"Check interval must be at least 15 minutes.",
):
parse_run_interval_minutes("14")
@ -34,7 +34,7 @@ def test_parse_request_delay_seconds_accepts_valid_value() -> None:
def test_parse_request_delay_seconds_rejects_below_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Request delay must be at least 2 seconds.",
match=r"Request delay must be at least 2 seconds.",
):
parse_request_delay_seconds("1")
@ -42,7 +42,7 @@ def test_parse_request_delay_seconds_rejects_below_minimum() -> None:
def test_parse_run_interval_minutes_rejects_below_configured_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Check interval must be at least 30 minutes.",
match=r"Check interval must be at least 30 minutes.",
):
parse_run_interval_minutes("29", minimum=30)
@ -50,7 +50,7 @@ def test_parse_run_interval_minutes_rejects_below_configured_minimum() -> None:
def test_parse_request_delay_seconds_rejects_below_configured_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Request delay must be at least 8 seconds.",
match=r"Request delay must be at least 8 seconds.",
):
parse_request_delay_seconds("7", minimum=8)
@ -82,7 +82,7 @@ def test_parse_nav_visible_pages_accepts_valid_pages() -> None:
def test_parse_nav_visible_pages_rejects_missing_required_pages() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Dashboard, Scholars, and Settings must remain visible.",
match=r"Dashboard, Scholars, and Settings must remain visible.",
):
parse_nav_visible_pages(["dashboard", "publications"])
@ -90,6 +90,6 @@ def test_parse_nav_visible_pages_rejects_missing_required_pages() -> None:
def test_parse_nav_visible_pages_rejects_unknown_page() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Unsupported navigation page id: reports",
match=r"Unsupported navigation page id: reports",
):
parse_nav_visible_pages(DEFAULT_NAV_VISIBLE_PAGES + ["reports"])
parse_nav_visible_pages([*DEFAULT_NAV_VISIBLE_PAGES, "reports"])