Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,48 @@ jobs:

- name: Production build
run: npm run build

# -------------------------------------------------------------------------
# ruff (lint) — added 2026-04-25 per issue #33. Gates Python lint on every
# PR and push to master. No live DB needed; --no-deps keeps it fast.
# Mirror the docker-compose-via-image pattern used by the bootstrap-failure
# job so CI lint and local lint produce the same result.
#
# NOTE: making this a REQUIRED check on master needs a separate
# branch-protection setting (Settings -> Branches -> master ->
# Require status checks -> add `ruff (lint)`). The workflow change here
# makes the job RUN; the protection setting makes it BLOCK merges.
# -------------------------------------------------------------------------
ruff:
name: ruff (lint)
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout
uses: actions/checkout@v6

- name: "Synthesize minimal .env (compose env_file: directive requires it to exist)"
# ruff check doesn't need any of these values, but docker-compose.yml's
# service-level `env_file: .env` directive requires the file to exist
# or `docker compose run` errors out before the container even starts.
# Match the per-job .env synthesis pattern used by `backend` and
# `bootstrap-failure`. Values are placeholders; ruff never reads them.
run: |
umask 077
cat > .env <<EOF
DATABASE_URL=postgresql+asyncpg://civicrecords:civicrecords@postgres:5432/civicrecords_test
JWT_SECRET=$(openssl rand -hex 32)
FIRST_ADMIN_EMAIL=admin@ci.local
FIRST_ADMIN_PASSWORD=$(openssl rand -hex 16)
OLLAMA_BASE_URL=http://ollama:11434
REDIS_URL=redis://redis:6379/0
AUDIT_RETENTION_DAYS=1095
TESTING=1
EOF

- name: Build api image
run: docker compose build api

- name: ruff check
run: docker compose run --rm --no-deps api ruff check .
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

No commits beyond v1.3.0 yet.
Changes since v1.3.0.

### Added

### Changed
- Build/CI: ruff is now a required CI check (`.github/workflows/ci.yml` job `ruff (lint)`); 82 pre-existing violations cleaned up (70 auto-fixed, 6 manually fixed including 4 E402 import-order fixes and 1 F841 unused-variable removal, 4 retained as inline `# noqa: E402` with rule-ID + justification). `scripts/verify-release.sh` gains a step 4 that runs `ruff check` against the api container. Closes #33.

### Deprecated

Expand Down
1 change: 0 additions & 1 deletion backend/alembic/versions/006_exemptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from civiccore.migrations.guards import (
idempotent_create_index,
Expand Down
4 changes: 2 additions & 2 deletions backend/app/analytics/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from app.auth.dependencies import UserRole, require_role
from app.database import get_async_session
from app.models.request import RecordsRequest, RequestStatus
from app.models.request import RecordsRequest
from app.schemas.analytics import OperationalMetrics

router = APIRouter(tags=["analytics"])
Expand Down Expand Up @@ -53,7 +53,7 @@ async def get_operational_metrics(
total_open = sum(v for k, v in by_status.items() if k not in closed_statuses)

# Overdue — use text cast to avoid PostgreSQL enum mismatch
from sqlalchemy import cast, String, text
from sqlalchemy import cast, String
overdue_stmt = select(func.count()).where(
RecordsRequest.statutory_deadline < now,
cast(RecordsRequest.status, String).notin_(["fulfilled", "closed"]),
Expand Down
2 changes: 1 addition & 1 deletion backend/app/connectors/file_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

logger = logging.getLogger(__name__)

from app.connectors.base import (
from app.connectors.base import ( # noqa: E402 module-level logger configured above must be ready before base imports trigger their own logging
BaseConnector,
DiscoveredRecord,
FetchedDocument,
Expand Down
2 changes: 0 additions & 2 deletions backend/app/connectors/imap_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
import imaplib
import logging
import time
from datetime import datetime, timezone
from email.message import EmailMessage

from app.connectors.base import (
BaseConnector,
Expand Down
1 change: 0 additions & 1 deletion backend/app/datasources/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ async def trigger_ingestion(source_id: uuid.UUID, session: AsyncSession = Depend

@router.post("/upload")
async def upload_file(file: UploadFile = File(...), session: AsyncSession = Depends(get_async_session), user: User = Depends(require_role(UserRole.STAFF))):
import tempfile
from pathlib import Path, PurePosixPath
upload_dir = Path("/tmp/civicrecords-uploads")
upload_dir.mkdir(parents=True, exist_ok=True)
Expand Down
11 changes: 4 additions & 7 deletions backend/app/exemptions/router.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import re
import uuid
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession

from app.audit.logger import write_audit_log
from app.auth.dependencies import require_role, require_department_or_404, has_department_access
from app.database import get_async_session
from app.exemptions.engine import scan_request_documents
from app.models.request import RecordsRequest
from app.models.city_profile import CityProfile
from app.models.exemption import (
DisclosureTemplate, ExemptionFlag, ExemptionRule, FlagStatus, RuleType,
)
from app.models.request import RecordsRequest
from app.models.user import User, UserRole
import re

from app.models.city_profile import CityProfile
from app.schemas.exemption import (
DisclosureTemplateCreate, DisclosureTemplateRead, DisclosureTemplateRendered,
DisclosureTemplateUpdate, ExemptionAccuracyReport, ExemptionDashboard,
Expand Down Expand Up @@ -140,9 +140,6 @@ async def get_rule_history(
]


from pydantic import BaseModel


class RuleTestRequest(BaseModel):
sample_text: str

Expand Down
1 change: 0 additions & 1 deletion backend/app/ingestion/llm_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ async def extract_text_from_image(image_path: Path, prefer_multimodal: bool = Tr

async def extract_text_from_scanned_pdf(pdf_path: Path, prefer_multimodal: bool = True, model: str | None = None) -> list[dict]:
model = model or settings.vision_model
from PIL import Image
import io
try:
import pdfplumber
Expand Down
1 change: 0 additions & 1 deletion backend/app/ingestion/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ def cleanup_audit_logs():
"""Archive and then delete audit log entries older than the configured retention period."""
import asyncio
import json
import os
from datetime import datetime, timezone, timedelta
from pathlib import Path
from sqlalchemy import delete, select
Expand Down
2 changes: 1 addition & 1 deletion backend/app/llm/context_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import logging
import re
from dataclasses import dataclass, field
from dataclasses import dataclass

logger = logging.getLogger(__name__)

Expand Down
6 changes: 3 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from app.audit import AuditMiddleware, audit_router
from app.auth import auth_router, register_router, users_router
from app.config import APP_VERSION, settings
from app.database import engine
from app.models.user import User, UserRole
from app.schemas.user import AdminUserCreate


class PortalModeResponse(BaseModel):
Expand All @@ -28,9 +31,6 @@ class PortalModeResponse(BaseModel):
"self-registration."
),
)
from app.database import engine
from app.models.user import User, UserRole
from app.schemas.user import AdminUserCreate


@asynccontextmanager
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/audit.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime

from sqlalchemy import DateTime, Index, String, Text, Boolean, func
from sqlalchemy import DateTime, Index, String, Boolean, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column

Expand Down
1 change: 0 additions & 1 deletion backend/app/models/connectors.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, Text, Integer, func
from sqlalchemy.dialects.postgresql import JSONB
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from pgvector.sqlalchemy import Vector
from sqlalchemy import (
Boolean, DateTime, Enum, Float, ForeignKey, Index, Integer,
Boolean, DateTime, Enum, ForeignKey, Index, Integer,
String, Text, TypeDecorator, func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/exemption.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime

from sqlalchemy import Boolean, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column

from app.models.user import Base
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/search.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime

from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy import DateTime, Float, ForeignKey, Integer, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column

Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/sync_failure.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime

from sqlalchemy import (
Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func,
DateTime, ForeignKey, Index, Integer, String, Text, func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
Expand Down
2 changes: 1 addition & 1 deletion backend/app/schemas/document.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, field_validator, model_validator
from pydantic import BaseModel, field_validator
from app.models.document import IngestionStatus, SourceType


Expand Down
2 changes: 1 addition & 1 deletion backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime

from fastapi_users import schemas
from pydantic import BaseModel, model_validator
from pydantic import model_validator

from app.models.user import UserRole

Expand Down
2 changes: 1 addition & 1 deletion backend/app/search/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from app.auth.dependencies import require_role, require_department_filter
from app.database import get_async_session
from app.models.departments import Department
from app.models.document import DataSource, Document, DocumentChunk
from app.models.document import DataSource, Document
from app.models.search import SearchQuery, SearchResult, SearchSession
from app.models.user import User, UserRole
from app.schemas.search import (
Expand Down
2 changes: 1 addition & 1 deletion backend/app/service_accounts/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import uuid
from hashlib import sha256

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

Expand Down
6 changes: 1 addition & 5 deletions backend/scripts/generate_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,16 @@
"""

import os
import sys
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable, KeepTogether
)
from reportlab.platypus.flowables import Flowable
from reportlab.graphics.shapes import Drawing, Rect, String, Line, Group
from reportlab.graphics import renderPDF

# ── Output path ──────────────────────────────────────────────────────────────
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
Expand Down Expand Up @@ -218,7 +215,6 @@ def draw(self):
fill=BLUE, text_color=WHITE)

# Horizontal connectors from FastAPI to side services
cx = api_x + (bw + 10) / 2

# Left services: PostgreSQL, pgvector
pg_x = 30
Expand Down
1 change: 0 additions & 1 deletion backend/scripts/seed_rules.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Seed exemption rules for all 50 states + DC."""
import asyncio
import uuid
from app.database import async_session_maker
from app.models.exemption import ExemptionRule, RuleType
from app.models.user import User
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from app.config import settings
from app.database import get_async_session
from app.main import create_app
from app.models.user import Base, User, UserRole
from app.models.user import User, UserRole
from app.models.departments import Department
from app.models.sync_failure import SyncFailure, SyncRunLog # noqa: F401 — registers with Base.metadata

Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_at_rest_encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from sqlalchemy import text

from app.config import Settings
from app.models.document import SourceType
from app.security.at_rest import (
AtRestDecryptionError,
decrypt_json,
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async def test_write_audit_log_direct(client: AsyncClient):
"""Verify audit log entries can be written and read back."""
from tests.conftest import test_session_maker
from app.audit.logger import write_audit_log
from sqlalchemy import select, func
from sqlalchemy import select
from app.models.audit import AuditLog

async with test_session_maker() as session:
Expand Down
4 changes: 1 addition & 3 deletions backend/tests/test_base_connector.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from app.connectors import get_connector
from app.connectors.base import BaseConnector, DiscoveredRecord, FetchedDocument, HealthCheckResult, HealthStatus


Expand Down Expand Up @@ -26,9 +27,6 @@ def test_base_connector_close_is_noop():
c.close() # must not raise AttributeError or any other error


from app.connectors import get_connector


def test_factory_rest_api():
connector = get_connector("rest_api", {
"base_url": "https://example.gov",
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_bootstrap_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
# Generate a real Fernet key once per test module so every _minimal_env()
# call gets a valid key by default; tests that specifically want to
# verify the encryption-key validator can override it.
from cryptography.fernet import Fernet as _Fernet
from cryptography.fernet import Fernet as _Fernet # noqa: E402 late import — comment block above documents T6/ENG-001 test override pattern requiring this be tied to module-level constants

_VALID_ENCRYPTION_KEY = _Fernet.generate_key().decode()

Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_circuit_breaker.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# backend/tests/test_circuit_breaker.py
"""P7 circuit breaker tests — real end-to-end calls asserting DB state."""
import uuid
from datetime import datetime, timezone
from datetime import timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -165,7 +165,7 @@ async def test_success_resets_consecutive_failure_count(db_session):
@pytest.mark.asyncio
async def test_zero_records_discovered_does_not_increment_counter(db_session):
"""discover() returns 0 five times → counter=0, no circuit open (M8)."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
import uuid

source_id = uuid.uuid4()
Expand Down
1 change: 0 additions & 1 deletion backend/tests/test_civiccore_migration_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@

import os
import subprocess
import sys
import uuid
from collections.abc import Iterator
from pathlib import Path
Expand Down
Loading