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:
parent
399276ea69
commit
bf04c77aa9
137 changed files with 3066 additions and 1900 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue