diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c52920 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint (Ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run Ruff linter + run: uv run ruff check . + + - name: Run Ruff formatter check + run: uv run ruff format --check . + + typecheck: + name: Type Check (ty) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run ty + run: uv run ty check + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run tests with coverage + env: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db + run: | + uv run pytest --cov --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.13' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + fail_ci_if_error: false diff --git a/README.md b/README.md index bf90055..854fe4f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ FastAPI Toolsets provides production-ready utilities for FastAPI applications built with async SQLAlchemy and PostgreSQL. It includes generic CRUD operations, a fixture system with dependency resolution, a Django-like CLI, standardized API responses, and structured exception handling with automatic OpenAPI documentation. +[![CI](https://github.com/d3vyce/fastapi-toolsets/actions/workflows/ci.yml/badge.svg)](https://github.com/d3vyce/fastapi-toolsets/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/d3vyce/fastapi-toolsets/graph/badge.svg)](https://codecov.io/gh/d3vyce/fastapi-toolsets) [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) diff --git a/src/fastapi_toolsets/cli/app.py b/src/fastapi_toolsets/cli/app.py index 03ea393..37ebd27 100644 --- a/src/fastapi_toolsets/cli/app.py +++ b/src/fastapi_toolsets/cli/app.py @@ -76,7 +76,9 @@ def _load_module_from_path(path: Path) -> object: path = path.resolve() # Add the parent directory to sys.path to support relative imports - parent_dir = str(path.parent.parent) # Go up two levels (e.g., from app/cli_config.py to project root) + parent_dir = str( + path.parent.parent + ) # Go up two levels (e.g., from app/cli_config.py to project root) if parent_dir not in sys.path: sys.path.insert(0, parent_dir) diff --git a/src/fastapi_toolsets/cli/commands/fixtures.py b/src/fastapi_toolsets/cli/commands/fixtures.py index 7e2219a..a800e2f 100644 --- a/src/fastapi_toolsets/cli/commands/fixtures.py +++ b/src/fastapi_toolsets/cli/commands/fixtures.py @@ -44,9 +44,7 @@ def _get_db_context(ctx: typer.Context): get_db_context = getattr(config, "get_db_context", None) if get_db_context is None: - raise typer.BadParameter( - "Config module must have a 'get_db_context' function." - ) + raise typer.BadParameter("Config module must have a 'get_db_context' function.") return get_db_context @@ -56,7 +54,11 @@ def list_fixtures( ctx: typer.Context, context: Annotated[ str | None, - typer.Option("--context", "-c", help="Filter by context (base, production, development, testing)."), + typer.Option( + "--context", + "-c", + help="Filter by context (base, production, development, testing).", + ), ] = None, ) -> None: """List all registered fixtures.""" @@ -110,7 +112,9 @@ def show_graph( typer.echo("\nFixture Dependency Graph:\n") for fixture in fixtures: - deps = f" -> [{', '.join(fixture.depends_on)}]" if fixture.depends_on else "" + deps = ( + f" -> [{', '.join(fixture.depends_on)}]" if fixture.depends_on else "" + ) typer.echo(f" {fixture.name}{deps}") @@ -119,15 +123,21 @@ def load( ctx: typer.Context, contexts: Annotated[ list[str] | None, - typer.Argument(help="Contexts to load (base, production, development, testing)."), + typer.Argument( + help="Contexts to load (base, production, development, testing)." + ), ] = None, strategy: Annotated[ str, - typer.Option("--strategy", "-s", help="Load strategy: merge, insert, skip_existing."), + typer.Option( + "--strategy", "-s", help="Load strategy: merge, insert, skip_existing." + ), ] = "merge", dry_run: Annotated[ bool, - typer.Option("--dry-run", "-n", help="Show what would be loaded without loading."), + typer.Option( + "--dry-run", "-n", help="Show what would be loaded without loading." + ), ] = False, ) -> None: """Load fixtures into the database.""" @@ -144,7 +154,9 @@ def load( try: load_strategy = LoadStrategy(strategy) except ValueError: - typer.echo(f"Invalid strategy: {strategy}. Use: merge, insert, skip_existing", err=True) + typer.echo( + f"Invalid strategy: {strategy}. Use: merge, insert, skip_existing", err=True + ) raise typer.Exit(1) # Resolve what will be loaded @@ -196,7 +208,9 @@ def show_fixture( typer.echo(f"\nFixture: {fixture.name}") typer.echo(f"Contexts: {', '.join(fixture.contexts)}") - typer.echo(f"Dependencies: {', '.join(fixture.depends_on) if fixture.depends_on else 'None'}") + typer.echo( + f"Dependencies: {', '.join(fixture.depends_on) if fixture.depends_on else 'None'}" + ) # Show instances instances = list(fixture.func()) diff --git a/src/fastapi_toolsets/fixtures/pytest_plugin.py b/src/fastapi_toolsets/fixtures/pytest_plugin.py index d4f8dc9..29b3446 100644 --- a/src/fastapi_toolsets/fixtures/pytest_plugin.py +++ b/src/fastapi_toolsets/fixtures/pytest_plugin.py @@ -62,7 +62,6 @@ from ..db import get_transaction from .fixtures import FixtureRegistry, LoadStrategy - def register_fixtures( registry: FixtureRegistry, namespace: dict[str, Any], diff --git a/tests/conftest.py b/tests/conftest.py index 6e97bf3..364bea9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,13 +5,13 @@ import os import pytest from pydantic import BaseModel from sqlalchemy import ForeignKey, String -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from fastapi_toolsets.crud import CrudFactory # PostgreSQL connection URL from environment or default for local development -DATABASE_URL = os.getenv( +DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv( "TEST_DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/fastapi_toolsets_test", ) @@ -149,7 +149,7 @@ async def engine(): @pytest.fixture(scope="function") -async def db_session(engine) -> AsyncSession: +async def db_session(engine): """Create a test database session with tables. Creates all tables before the test and drops them after.