diff --git a/README.md b/README.md index b913042..d99bcb0 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,15 @@ uv add fastapi-toolsets ## Features -- **CRUD**: Generic async CRUD operations with `CrudFactory` -- **Fixtures**: Fixture system with dependency management, context support and pytest integration +- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal +- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection +- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters +- **Fixtures**: Fixture system with dependency management, context support, and pytest integration - **CLI**: Django-like command-line interface with fixture management and custom commands support -- **Standardized API Responses**: Consistent response format across your API +- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase` - **Exception Handling**: Structured error responses with automatic OpenAPI documentation +- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger` +- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities ## License diff --git a/src/fastapi_toolsets/cli/config.py b/src/fastapi_toolsets/cli/config.py index 600b3f5..a4963c0 100644 --- a/src/fastapi_toolsets/cli/config.py +++ b/src/fastapi_toolsets/cli/config.py @@ -18,10 +18,16 @@ def _ensure_project_in_path(): def import_from_string(import_path: str): - """Import an object from a string path like 'module.submodule:attribute'. + """Import an object from a dotted string path. + + Args: + import_path: Import path in ``"module.submodule:attribute"`` format + + Returns: + The imported attribute Raises: - typer.BadParameter: If the import path is invalid or import fails. + typer.BadParameter: If the import path is invalid or import fails """ if ":" not in import_path: raise typer.BadParameter( diff --git a/src/fastapi_toolsets/cli/pyproject.py b/src/fastapi_toolsets/cli/pyproject.py index 859658a..06f724b 100644 --- a/src/fastapi_toolsets/cli/pyproject.py +++ b/src/fastapi_toolsets/cli/pyproject.py @@ -10,6 +10,12 @@ def find_pyproject(start_path: Path | None = None) -> Path | None: """Find pyproject.toml by walking up the directory tree. Similar to how pytest, black, and ruff discover their config files. + + Args: + start_path: Directory to start searching from. Defaults to cwd. + + Returns: + Path to pyproject.toml, or None if not found. """ path = (start_path or Path.cwd()).resolve() diff --git a/src/fastapi_toolsets/db.py b/src/fastapi_toolsets/db.py index 6939b30..c69d76f 100644 --- a/src/fastapi_toolsets/db.py +++ b/src/fastapi_toolsets/db.py @@ -210,6 +210,20 @@ async def wait_for_row_change( Raises: LookupError: If the row does not exist or is deleted during polling TimeoutError: If timeout expires before a change is detected + + Example: + from fastapi_toolsets.db import wait_for_row_change + + # Wait for any column to change + updated = await wait_for_row_change(session, User, user_id) + + # Watch specific columns with a timeout + updated = await wait_for_row_change( + session, User, user_id, + columns=["status", "email"], + interval=1.0, + timeout=30.0, + ) """ instance = await session.get(model, pk_value) if instance is None: diff --git a/src/fastapi_toolsets/exceptions/__init__.py b/src/fastapi_toolsets/exceptions/__init__.py index b3c08d5..714b6cb 100644 --- a/src/fastapi_toolsets/exceptions/__init__.py +++ b/src/fastapi_toolsets/exceptions/__init__.py @@ -1,3 +1,5 @@ +"""Standardized API exceptions and error response handlers.""" + from .exceptions import ( ApiError, ApiException, diff --git a/src/fastapi_toolsets/exceptions/exceptions.py b/src/fastapi_toolsets/exceptions/exceptions.py index fa15153..c00d0dc 100644 --- a/src/fastapi_toolsets/exceptions/exceptions.py +++ b/src/fastapi_toolsets/exceptions/exceptions.py @@ -87,6 +87,12 @@ class InsufficientRolesError(ForbiddenError): ) def __init__(self, required_roles: list[str], user_roles: set[str] | None = None): + """Initialize the exception. + + Args: + required_roles: Roles needed to access the resource + user_roles: Roles the current user has, if known + """ self.required_roles = required_roles self.user_roles = user_roles @@ -130,6 +136,11 @@ class NoSearchableFieldsError(ApiException): ) def __init__(self, model: type) -> None: + """Initialize the exception. + + Args: + model: The SQLAlchemy model class that has no searchable fields + """ self.model = model detail = ( f"No searchable fields found for model '{model.__name__}'. " diff --git a/src/fastapi_toolsets/exceptions/handler.py b/src/fastapi_toolsets/exceptions/handler.py index 6ede049..55c77dd 100644 --- a/src/fastapi_toolsets/exceptions/handler.py +++ b/src/fastapi_toolsets/exceptions/handler.py @@ -12,6 +12,25 @@ from .exceptions import ApiException def init_exceptions_handlers(app: FastAPI) -> FastAPI: + """Register exception handlers and custom OpenAPI schema on a FastAPI app. + + Installs handlers for :class:`ApiException`, validation errors, and + unhandled exceptions, and replaces the default 422 schema with a + consistent error format. + + Args: + app: FastAPI application instance + + Returns: + The same FastAPI instance (for chaining) + + Example: + from fastapi import FastAPI + from fastapi_toolsets.exceptions import init_exceptions_handlers + + app = FastAPI() + init_exceptions_handlers(app) + """ _register_exception_handlers(app) app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign] return app diff --git a/src/fastapi_toolsets/fixtures/__init__.py b/src/fastapi_toolsets/fixtures/__init__.py index 0157c09..89804cb 100644 --- a/src/fastapi_toolsets/fixtures/__init__.py +++ b/src/fastapi_toolsets/fixtures/__init__.py @@ -1,3 +1,5 @@ +"""Fixture system for seeding databases with dependency resolution.""" + from .enum import LoadStrategy from .registry import Context, FixtureRegistry from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context diff --git a/src/fastapi_toolsets/fixtures/enum.py b/src/fastapi_toolsets/fixtures/enum.py index 6a0b53b..2d24090 100644 --- a/src/fastapi_toolsets/fixtures/enum.py +++ b/src/fastapi_toolsets/fixtures/enum.py @@ -1,3 +1,5 @@ +"""Enums for fixture loading strategies and contexts.""" + from enum import Enum diff --git a/src/fastapi_toolsets/fixtures/utils.py b/src/fastapi_toolsets/fixtures/utils.py index 7c2acfb..275224a 100644 --- a/src/fastapi_toolsets/fixtures/utils.py +++ b/src/fastapi_toolsets/fixtures/utils.py @@ -1,3 +1,5 @@ +"""Fixture loading utilities for database seeding.""" + from collections.abc import Callable, Sequence from typing import Any, TypeVar diff --git a/src/fastapi_toolsets/logger.py b/src/fastapi_toolsets/logger.py index ad5bab6..c40d95b 100644 --- a/src/fastapi_toolsets/logger.py +++ b/src/fastapi_toolsets/logger.py @@ -35,6 +35,12 @@ def configure_logging( Returns: The configured Logger instance. + + Example: + from fastapi_toolsets.logger import configure_logging + + logger = configure_logging("DEBUG") + logger.info("Application started") """ formatter = logging.Formatter(fmt) @@ -75,6 +81,13 @@ def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[ Returns: A Logger instance. + + Example: + from fastapi_toolsets.logger import get_logger + + logger = get_logger() # uses caller's __name__ + logger = get_logger("myapp") # explicit name + logger = get_logger(None) # root logger """ if name is _SENTINEL: name = sys._getframe(1).f_globals.get("__name__") diff --git a/src/fastapi_toolsets/pytest/__init__.py b/src/fastapi_toolsets/pytest/__init__.py index 086a176..9f67307 100644 --- a/src/fastapi_toolsets/pytest/__init__.py +++ b/src/fastapi_toolsets/pytest/__init__.py @@ -1,3 +1,5 @@ +"""Pytest helpers for FastAPI testing: sessions, clients, and fixtures.""" + from .plugin import register_fixtures from .utils import ( cleanup_tables, diff --git a/src/fastapi_toolsets/pytest/utils.py b/src/fastapi_toolsets/pytest/utils.py index 27fea44..a1d9f6c 100644 --- a/src/fastapi_toolsets/pytest/utils.py +++ b/src/fastapi_toolsets/pytest/utils.py @@ -33,7 +33,6 @@ async def create_async_client( An AsyncClient configured for the app. Example: - ```python from fastapi import FastAPI from fastapi_toolsets.pytest import create_async_client @@ -47,7 +46,6 @@ async def create_async_client( async def test_endpoint(client: AsyncClient): response = await client.get("/health") assert response.status_code == 200 - ``` """ transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url=base_url) as client: @@ -79,7 +77,6 @@ async def create_db_session( An AsyncSession ready for database operations. Example: - ```python from fastapi_toolsets.pytest import create_db_session from app.models import Base @@ -94,7 +91,6 @@ async def create_db_session( user = User(name="test") db_session.add(user) await db_session.commit() - ``` """ engine = create_async_engine(database_url, echo=echo) @@ -151,7 +147,6 @@ def worker_database_url(database_url: str, default_test_db: str) -> str: A database URL with a worker- or default-specific database name. Example: - ```python # With PYTEST_XDIST_WORKER="gw0": url = worker_database_url( "postgresql+asyncpg://user:pass@localhost/test_db", @@ -165,7 +160,6 @@ def worker_database_url(database_url: str, default_test_db: str) -> str: default_test_db="test", ) # "postgresql+asyncpg://user:pass@localhost/test_db_test" - ``` """ worker = _get_xdist_worker(default_test_db=default_test_db) @@ -198,7 +192,6 @@ async def create_worker_database( The worker-specific database URL. Example: - ```python from fastapi_toolsets.pytest import ( create_worker_database, create_db_session, cleanup_tables ) @@ -215,7 +208,6 @@ async def create_worker_database( async with create_db_session(worker_db_url, Base) as session: yield session await cleanup_tables(session, Base) - ``` """ worker_url = worker_database_url( database_url=database_url, default_test_db=default_test_db @@ -256,13 +248,11 @@ async def cleanup_tables( base: SQLAlchemy DeclarativeBase class containing model metadata. Example: - ```python @pytest.fixture async def db_session(worker_db_url): async with create_db_session(worker_db_url, Base) as session: yield session await cleanup_tables(session, Base) - ``` """ tables = base.metadata.sorted_tables if not tables: