diff --git a/app/api/media.py b/app/api/media.py index 00943bd..878c347 100644 --- a/app/api/media.py +++ b/app/api/media.py @@ -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, ) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 9b9f053..128fd65 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -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, diff --git a/app/api/routers/runs.py b/app/api/routers/runs.py index c25a127..b55de66 100644 --- a/app/api/routers/runs.py +++ b/app/api/routers/runs.py @@ -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, diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index fcdd1c4..0792aa5 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -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, ), diff --git a/app/security/csrf.py b/app/security/csrf.py index 00119a9..5c181c9 100644 --- a/app/security/csrf.py +++ b/app/security/csrf.py @@ -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( diff --git a/app/services/domains/arxiv/rate_limit.py b/app/services/domains/arxiv/rate_limit.py index ddc5cab..b5e5eed 100644 --- a/app/services/domains/arxiv/rate_limit.py +++ b/app/services/domains/arxiv/rate_limit.py @@ -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( diff --git a/app/services/domains/ingestion/application.py b/app/services/domains/ingestion/application.py index 1098c1b..9707c65 100644 --- a/app/services/domains/ingestion/application.py +++ b/app/services/domains/ingestion/application.py @@ -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,9 +290,8 @@ 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): - raise RuntimeError(f"Layout warning marked as terminal for scholar_id={scholar_id}.") + 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(): raise RuntimeError(f"Malformed publication title for scholar_id={scholar_id}.") @@ -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, diff --git a/app/services/domains/ingestion/fingerprints.py b/app/services/domains/ingestion/fingerprints.py index 7c3156d..a39c72d 100644 --- a/app/services/domains/ingestion/fingerprints.py +++ b/app/services/domains/ingestion/fingerprints.py @@ -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: diff --git a/app/services/domains/ingestion/scheduler.py b/app/services/domains/ingestion/scheduler.py index 4aa8bc0..df20d2d 100644 --- a/app/services/domains/ingestion/scheduler.py +++ b/app/services/domains/ingestion/scheduler.py @@ -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, diff --git a/app/services/domains/openalex/matching.py b/app/services/domains/openalex/matching.py index 40c25ba..8b3c6d5 100644 --- a/app/services/domains/openalex/matching.py +++ b/app/services/domains/openalex/matching.py @@ -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] diff --git a/app/services/domains/portability/__init__.py b/app/services/domains/portability/__init__.py index 28aae8b..2c081c6 100644 --- a/app/services/domains/portability/__init__.py +++ b/app/services/domains/portability/__init__.py @@ -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, +) diff --git a/app/services/domains/publication_identifiers/application.py b/app/services/domains/publication_identifiers/application.py index 47fe940..b758e84 100644 --- a/app/services/domains/publication_identifiers/application.py +++ b/app/services/domains/publication_identifiers/application.py @@ -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, diff --git a/app/services/domains/publications/__init__.py b/app/services/domains/publications/__init__.py index 371b02c..058e728 100644 --- a/app/services/domains/publications/__init__.py +++ b/app/services/domains/publications/__init__.py @@ -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, +) diff --git a/app/services/domains/publications/pdf_queue.py b/app/services/domains/publications/pdf_queue.py index 880b1a2..1456fc5 100644 --- a/app/services/domains/publications/pdf_queue.py +++ b/app/services/domains/publications/pdf_queue.py @@ -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( diff --git a/app/services/domains/publications/pdf_resolution_pipeline.py b/app/services/domains/publications/pdf_resolution_pipeline.py index 88a07ca..c537a4c 100644 --- a/app/services/domains/publications/pdf_resolution_pipeline.py +++ b/app/services/domains/publications/pdf_resolution_pipeline.py @@ -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 diff --git a/app/services/domains/publications/queries.py b/app/services/domains/publications/queries.py index 89dd1d1..ac6090f 100644 --- a/app/services/domains/publications/queries.py +++ b/app/services/domains/publications/queries.py @@ -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 diff --git a/app/services/domains/runs/__init__.py b/app/services/domains/runs/__init__.py index 848f6e0..829f7f0 100644 --- a/app/services/domains/runs/__init__.py +++ b/app/services/domains/runs/__init__.py @@ -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, +) diff --git a/app/services/domains/runs/summary.py b/app/services/domains/runs/summary.py index b18ceb8..58cb7e3 100644 --- a/app/services/domains/runs/summary.py +++ b/app/services/domains/runs/summary.py @@ -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 diff --git a/app/services/domains/scholar/parser.py b/app/services/domains/scholar/parser.py index 26b2faf..e8ab1bb 100644 --- a/app/services/domains/scholar/parser.py +++ b/app/services/domains/scholar/parser.py @@ -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, ) diff --git a/app/services/domains/scholar/profile_rows.py b/app/services/domains/scholar/profile_rows.py index 38faf0a..0ad8f15 100644 --- a/app/services/domains/scholar/profile_rows.py +++ b/app/services/domains/scholar/profile_rows.py @@ -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: diff --git a/app/services/domains/scholars/application.py b/app/services/domains/scholars/application.py index 3b66643..51c1490 100644 --- a/app/services/domains/scholars/application.py +++ b/app/services/domains/scholars/application.py @@ -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), diff --git a/app/services/domains/settings/application.py b/app/services/domains/settings/application.py index 3636bf6..e35971c 100644 --- a/app/services/domains/settings/application.py +++ b/app/services/domains/settings/application.py @@ -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, *, diff --git a/app/services/domains/unpaywall/application.py b/app/services/domains/unpaywall/application.py index 0febe51..1258643 100644 --- a/app/services/domains/unpaywall/application.py +++ b/app/services/domains/unpaywall/application.py @@ -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: diff --git a/tests/unit/test_user_settings.py b/tests/unit/test_user_settings.py index 83b1bc0..7897de2 100644 --- a/tests/unit/test_user_settings.py +++ b/tests/unit/test_user_settings.py @@ -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"])