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
94 changes: 69 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@ on:
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
quality-checks:
lint:
name: Lint
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
run: uv python install 3.13

- name: Install dependencies
run: uv sync --all-extras
Expand All @@ -33,41 +34,84 @@ jobs:
- name: Run ruff format check
run: uv run ruff format --check src/ tests/

- name: Run mypy type checking
run: uv run mypy src/ --strict
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
run: uv python install 3.13

- name: Install dependencies
run: uv sync --all-extras

- name: Run mypy
run: uv run mypy src/

test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
run: uv python install 3.13

- name: Install dependencies
run: uv sync --all-extras

- name: Run tests with coverage
run: uv run pytest --cov=src --cov-report=term-missing --cov-report=xml --cov-fail-under=80
run: uv run pytest tests/ --cov=src --cov-report=xml --cov-report=term-missing

- name: Upload coverage reports
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
if: always()
with:
file: ./coverage.xml
files: ./coverage.xml
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

security-audit:
version-check:
name: Version Consistency
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
run: uv python install 3.13

- name: Install dependencies
run: uv sync --all-extras
- name: Check version consistency
run: uv run python scripts/check_version.py

- name: Run pip-audit
run: uv run pip-audit
continue-on-error: true
all-checks:
name: All Checks Passed
runs-on: ubuntu-latest
needs: [lint, typecheck, test, version-check]
if: always()
steps:
- name: Check all jobs passed
run: |
if [[ "${{ needs.lint.result }}" != "success" ]] || \
[[ "${{ needs.typecheck.result }}" != "success" ]] || \
[[ "${{ needs.test.result }}" != "success" ]] || \
[[ "${{ needs.version-check.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
echo "All checks passed!"
98 changes: 98 additions & 0 deletions scripts/check_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Validate version consistency between __init__.py and pyproject.toml.

This script ensures the version in src/__init__.py matches
the version in pyproject.toml to prevent version drift.

Exit codes:
0: Versions match
1: Version mismatch or error
"""

from __future__ import annotations

import re
import sys
import tomllib
from pathlib import Path


def get_init_version(init_file: Path) -> str | None:
"""Extract version from __init__.py file.

Args:
init_file: Path to __init__.py file.

Returns:
Version string if found, None otherwise.
"""
content = init_file.read_text(encoding="utf-8")
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE)
return match.group(1) if match else None


def get_pyproject_version(pyproject_file: Path) -> str | None:
"""Extract version from pyproject.toml file.

Args:
pyproject_file: Path to pyproject.toml file.

Returns:
Version string if found, None otherwise.
"""
try:
content = pyproject_file.read_text(encoding="utf-8")
data = tomllib.loads(content)
return data.get("project", {}).get("version")
except (tomllib.TOMLDecodeError, KeyError):
return None


def main() -> int:
"""Main validation logic.

Returns:
Exit code (0 for success, 1 for failure).
"""
# Determine project root (script is in scripts/ directory)
project_root = Path(__file__).parent.parent
init_file = project_root / "src" / "__init__.py"
pyproject_file = project_root / "pyproject.toml"

# Check files exist
if not init_file.exists():
print(f"❌ Error: {init_file} not found", file=sys.stderr)
return 1

if not pyproject_file.exists():
print(f"❌ Error: {pyproject_file} not found", file=sys.stderr)
return 1

# Extract versions
init_version = get_init_version(init_file)
if init_version is None:
print(f"❌ Error: Could not find __version__ in {init_file}", file=sys.stderr)
return 1

pyproject_version = get_pyproject_version(pyproject_file)
if pyproject_version is None:
print(f"❌ Error: Could not find version in {pyproject_file}", file=sys.stderr)
return 1

# Compare versions
if init_version != pyproject_version:
print("❌ Version mismatch detected!", file=sys.stderr)
print(f" src/__init__.py: {init_version}", file=sys.stderr)
print(f" pyproject.toml: {pyproject_version}", file=sys.stderr)
print(file=sys.stderr)
print("Please update both files to match.", file=sys.stderr)
print("See RELEASING.md for version management guidelines.", file=sys.stderr)
return 1

# Success
print(f"✅ Version consistency check passed: {init_version}")
return 0


if __name__ == "__main__":
sys.exit(main())
2 changes: 2 additions & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
"""Retriever - AI-powered Q&A system for shelter volunteers."""

__version__ = "0.1.0"
2 changes: 1 addition & 1 deletion src/infrastructure/database/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async def disconnect(self) -> None:
logger.info("database_disconnected", path=str(self._db_path))

@asynccontextmanager
async def transaction(self) -> AsyncGenerator[aiosqlite.Connection, None]:
async def transaction(self) -> AsyncGenerator[aiosqlite.Connection]:
"""Context manager for database transactions.

Yields:
Expand Down
2 changes: 1 addition & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
"""Application lifespan handler for startup/shutdown."""
global _database

Expand Down
29 changes: 12 additions & 17 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
"""Tests for health check endpoint."""

import pytest
from fastapi.testclient import TestClient

from src.config import get_settings
from src.main import app


@pytest.fixture
def client() -> TestClient:
"""Create test client fixture."""
return TestClient(app)


def test_health_check_returns_healthy(client: TestClient) -> None:
def test_health_check_returns_healthy() -> None:
"""Health endpoint should return healthy status."""
response = client.get("/health")
with TestClient(app) as client:
response = client.get("/health")

assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "version" in data
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "version" in data


def test_health_check_includes_version(client: TestClient) -> None:
def test_health_check_includes_version() -> None:
"""Health endpoint should include app version from settings."""
response = client.get("/health")
with TestClient(app) as client:
response = client.get("/health")

data = response.json()
assert data["version"] == get_settings().app_version
data = response.json()
assert data["version"] == get_settings().app_version
8 changes: 4 additions & 4 deletions tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@


@pytest.fixture
def client() -> Generator[TestClient, None, None]:
def client() -> Generator[TestClient]:
"""Create test client fixture with mocked authentication."""
# Clear any existing overrides
app.dependency_overrides.clear()
Expand All @@ -49,7 +49,7 @@ class TestAskEndpointValidation:
"""Tests for ask endpoint input validation."""

@pytest.fixture(autouse=True)
def use_fallback_llm(self) -> Generator[None, None, None]:
def use_fallback_llm(self) -> Generator[None]:
"""Use fallback LLM for all validation tests (faster)."""
app.dependency_overrides[get_llm_provider] = lambda: None
yield
Expand Down Expand Up @@ -150,7 +150,7 @@ class TestRateLimiting:
"""

@pytest.fixture
def fresh_rate_limit_client(self) -> Generator[TestClient, None, None]:
def fresh_rate_limit_client(self) -> Generator[TestClient]:
"""Client with fresh rate limit storage."""
from src.api.rate_limit import limiter

Expand Down Expand Up @@ -191,7 +191,7 @@ class TestChunkFiltering:
"""Tests for citation chunk limiting in responses."""

@pytest.fixture(autouse=True)
def reset_rate_limiter(self) -> Generator[None, None, None]:
def reset_rate_limiter(self) -> Generator[None]:
"""Reset rate limiter before each test."""
from src.api.rate_limit import limiter

Expand Down