ci: add ruff linting and mypy type checking

Add ruff and mypy to dev dependencies with configuration in pyproject.toml.
Add a lint CI job that runs ruff check, ruff format --check, and mypy.
Auto-fix import sorting and formatting across the codebase. Exclude
alembic/versions from linting (auto-generated migrations). Ignore B008
(FastAPI Depends pattern) and RUF001 (unicode in user-facing strings).

21 ruff lint errors and 50 mypy errors remain for manual review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Visser 2026-02-26 22:11:41 +01:00
parent 399276ea69
commit bf04c77aa9
137 changed files with 3066 additions and 1900 deletions

View file

@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.security import PasswordService
def login_user(client: TestClient, *, email: str, password: str) -> None:
bootstrap_response = client.get("/api/v1/auth/csrf")
assert bootstrap_response.status_code == 200

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
@ -12,8 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.runtime_deps import get_scholar_source
from app.main import app
from app.services.domains.publications.types import PublicationListItem
from app.services.domains.settings import application as user_settings_service
from app.services.domains.scholar.source import FetchResult
from app.services.domains.settings import application as user_settings_service
from app.settings import settings
from tests.integration.helpers import insert_user, login_user
@ -412,10 +412,7 @@ async def test_api_admin_dbops_integrity_and_repair_flow(db_session: AsyncSessio
assert integrity_response.status_code == 200
integrity_payload = integrity_response.json()["data"]
assert integrity_payload["status"] in {"ok", "warning", "failed"}
assert any(
check["name"] == "missing_pdf_url"
for check in integrity_payload["checks"]
)
assert any(check["name"] == "missing_pdf_url" for check in integrity_payload["checks"])
repair_response = client.post(
"/api/v1/admin/db/repairs/publication-links",
@ -450,10 +447,7 @@ async def test_api_admin_dbops_integrity_and_repair_flow(db_session: AsyncSessio
assert page_two_payload["has_prev"] is True
untracked_response = client.get("/api/v1/admin/db/pdf-queue?limit=20&status=untracked")
assert untracked_response.status_code == 200
assert any(
item["status"] == "untracked"
for item in untracked_response.json()["data"]["items"]
)
assert any(item["status"] == "untracked" for item in untracked_response.json()["data"]["items"])
link_count_result = await db_session.execute(
text("SELECT count(*) FROM scholar_publications WHERE publication_id = :publication_id"),
@ -1272,9 +1266,9 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
assert settings_payload["policy"]["min_run_interval_minutes"] == user_settings_service.resolve_run_interval_minimum(
settings.ingestion_min_run_interval_minutes
)
assert settings_payload["policy"]["min_request_delay_seconds"] == user_settings_service.resolve_request_delay_minimum(
settings.ingestion_min_request_delay_seconds
)
assert settings_payload["policy"][
"min_request_delay_seconds"
] == user_settings_service.resolve_request_delay_minimum(settings.ingestion_min_request_delay_seconds)
assert settings_payload["policy"]["automation_allowed"] is settings.ingestion_automation_allowed
assert settings_payload["policy"]["manual_run_allowed"] is settings.ingestion_manual_run_allowed
assert settings_payload["policy"]["blocked_failure_threshold"] == max(
@ -1482,10 +1476,7 @@ async def test_api_runs_manual_and_queue_actions(db_session: AsyncSession) -> No
queue_list_response = client.get("/api/v1/runs/queue/items")
assert queue_list_response.status_code == 200
assert any(
int(item["id"]) == queue_item_id
for item in queue_list_response.json()["data"]["queue_items"]
)
assert any(int(item["id"]) == queue_item_id for item in queue_list_response.json()["data"]["queue_items"])
retry_response = client.post(f"/api/v1/runs/queue/{queue_item_id}/retry", headers=headers)
assert retry_response.status_code == 200
@ -1766,7 +1757,7 @@ async def test_api_settings_clears_expired_scrape_safety_cooldown(
)
user_settings.scrape_safety_state = {"blocked_start_count": 2}
user_settings.scrape_cooldown_reason = "blocked_failure_threshold_exceeded"
user_settings.scrape_cooldown_until = datetime.now(timezone.utc) - timedelta(seconds=15)
user_settings.scrape_cooldown_until = datetime.now(UTC) - timedelta(seconds=15)
await db_session.commit()
client = TestClient(app)

View file

@ -160,10 +160,7 @@ async def _assert_apply_effects(
)
baseline_completed = await _count_rows(
db_session,
sql=(
"SELECT count(*) FROM scholar_profiles "
"WHERE id = :scholar_profile_id AND baseline_completed = false"
),
sql=("SELECT count(*) FROM scholar_profiles WHERE id = :scholar_profile_id AND baseline_completed = false"),
params={"scholar_profile_id": scholar_profile_id},
)
publication_count = await _count_rows(

View file

@ -1,22 +1,24 @@
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
from app.db.models import CrawlRun, Publication, ScholarProfile, ScholarPublication, RunStatus, RunTriggerType
from app.db.models import CrawlRun, Publication, RunStatus, RunTriggerType, ScholarProfile, ScholarPublication
from app.services.domains.ingestion.application import ScholarIngestionService
from app.services.domains.openalex.types import OpenAlexWork
from tests.integration.helpers import insert_user
@pytest.mark.integration
@pytest.mark.asyncio
async def test_deferred_enrichment_sweeps_previous_runs(db_session: AsyncSession) -> None:
# 1. Setup: Create user and scholar
user_id = await insert_user(db_session, email="test@example.com", password="password123")
scholar = ScholarProfile(
user_id=user_id,
scholar_id="SaiiI5MAAAAJ",
@ -25,17 +27,17 @@ async def test_deferred_enrichment_sweeps_previous_runs(db_session: AsyncSession
)
db_session.add(scholar)
await db_session.flush()
# 2. Simulate a previous FAILED run that left an un-enriched publication
failed_run = CrawlRun(
user_id=user_id,
status=RunStatus.FAILED,
trigger_type=RunTriggerType.MANUAL,
start_dt=datetime.now(timezone.utc),
start_dt=datetime.now(UTC),
)
db_session.add(failed_run)
await db_session.flush()
pub = Publication(
title_raw="A fast quantum mechanical algorithm for database search",
title_normalized="a fast quantum mechanical algorithm for database search",
@ -45,7 +47,7 @@ async def test_deferred_enrichment_sweeps_previous_runs(db_session: AsyncSession
)
db_session.add(pub)
await db_session.flush()
link = ScholarPublication(
scholar_profile_id=scholar.id,
publication_id=pub.id,
@ -53,17 +55,17 @@ async def test_deferred_enrichment_sweeps_previous_runs(db_session: AsyncSession
)
db_session.add(link)
await db_session.commit()
# 3. Create a NEW run
new_run = CrawlRun(
user_id=user_id,
status=RunStatus.RUNNING,
trigger_type=RunTriggerType.MANUAL,
start_dt=datetime.now(timezone.utc),
start_dt=datetime.now(UTC),
)
db_session.add(new_run)
await db_session.commit()
# 4. Mock OpenAlex client to return enrichment data
mock_work = OpenAlexWork(
openalex_id="W1234567",
@ -76,26 +78,30 @@ async def test_deferred_enrichment_sweeps_previous_runs(db_session: AsyncSession
is_oa=True,
oa_url="http://example.com/grover.pdf",
)
mock_source = MagicMock()
service = ScholarIngestionService(source=mock_source)
# We patch the client at its source, and also mock arXiv to avoid real HTTP calls
with patch("app.services.domains.openalex.client.OpenAlexClient") as MockClient, \
patch("app.services.domains.arxiv.application.discover_arxiv_id_for_publication", new=AsyncMock(return_value=None)):
with (
patch("app.services.domains.openalex.client.OpenAlexClient") as MockClient,
patch(
"app.services.domains.arxiv.application.discover_arxiv_id_for_publication", new=AsyncMock(return_value=None)
),
):
mock_instance = MockClient.return_value
mock_instance.get_works_by_filter = AsyncMock(return_value=[mock_work])
# 5. Execute the enrichment pass for the NEW run
await service._enrich_pending_publications(db_session, run_id=new_run.id)
await db_session.commit()
# 6. Verification: The publication from the FAILED run should now be enriched
await db_session.refresh(pub)
assert pub.openalex_enriched is True
assert pub.citation_count == 1000
assert pub.pdf_url == "http://example.com/grover.pdf"
# Double check it was indeed processed
stmt = select(Publication).where(Publication.id == pub.id)
result = await db_session.execute(stmt)

View file

@ -34,8 +34,7 @@ def _blocked_fetch(*, scholar_id: str, body: str) -> FetchResult:
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
status_code=200,
final_url=(
"https://accounts.google.com/v3/signin/identifier"
"?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
"https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
),
body=body,
error=None,

View file

@ -29,9 +29,7 @@ EXPECTED_REVISION = "20260226_0024"
@pytest.mark.migrations
@pytest.mark.asyncio
async def test_migration_creates_expected_tables(db_session: AsyncSession) -> None:
result = await db_session.execute(
text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
)
result = await db_session.execute(text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'"))
table_names = {row[0] for row in result}
assert EXPECTED_TABLES.issubset(table_names)

View file

@ -338,8 +338,8 @@ async def test_partial_discovery_exception_keeps_new_pub_count_consistent(
monkeypatch.setattr(service, "_resolve_publication", _resolve_publication_stub)
from app.services.domains.scholar.parser_types import PublicationCandidate
from app.db.models import ScholarProfile
from app.services.domains.scholar.parser_types import PublicationCandidate
scholar = await db_session.get(ScholarProfile, scholar_profile_id)
assert scholar is not None
@ -505,9 +505,7 @@ async def test_publications_pagination_snapshot_stays_stable_across_inserts(
)
assert first_page_again_response.status_code == 200
first_page_again = first_page_again_response.json()["data"]
first_page_again_ids = [
int(item["publication_id"]) for item in first_page_again["publications"]
]
first_page_again_ids = [int(item["publication_id"]) for item in first_page_again["publications"]]
assert first_page_again_ids == first_page_ids
assert not (set(first_page_ids) & set(second_page_ids))