doc: add missing docstring + add missing feature to README (#57)

This commit is contained in:
d3vyce
2026-02-12 18:09:39 +01:00
committed by GitHub
parent c8c263ca8f
commit 8825c772ce
13 changed files with 88 additions and 15 deletions

View File

@@ -26,11 +26,15 @@ uv add fastapi-toolsets
## Features ## Features
- **CRUD**: Generic async CRUD operations with `CrudFactory` - **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in search with relationship traversal
- **Fixtures**: Fixture system with dependency management, context support and pytest integration - **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 - **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 - **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 ## License

View File

@@ -18,10 +18,16 @@ def _ensure_project_in_path():
def import_from_string(import_path: str): 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: 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: if ":" not in import_path:
raise typer.BadParameter( raise typer.BadParameter(

View File

@@ -10,6 +10,12 @@ def find_pyproject(start_path: Path | None = None) -> Path | None:
"""Find pyproject.toml by walking up the directory tree. """Find pyproject.toml by walking up the directory tree.
Similar to how pytest, black, and ruff discover their config files. 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() path = (start_path or Path.cwd()).resolve()

View File

@@ -210,6 +210,20 @@ async def wait_for_row_change(
Raises: Raises:
LookupError: If the row does not exist or is deleted during polling LookupError: If the row does not exist or is deleted during polling
TimeoutError: If timeout expires before a change is detected 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) instance = await session.get(model, pk_value)
if instance is None: if instance is None:

View File

@@ -1,3 +1,5 @@
"""Standardized API exceptions and error response handlers."""
from .exceptions import ( from .exceptions import (
ApiError, ApiError,
ApiException, ApiException,

View File

@@ -87,6 +87,12 @@ class InsufficientRolesError(ForbiddenError):
) )
def __init__(self, required_roles: list[str], user_roles: set[str] | None = None): 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.required_roles = required_roles
self.user_roles = user_roles self.user_roles = user_roles
@@ -130,6 +136,11 @@ class NoSearchableFieldsError(ApiException):
) )
def __init__(self, model: type) -> None: def __init__(self, model: type) -> None:
"""Initialize the exception.
Args:
model: The SQLAlchemy model class that has no searchable fields
"""
self.model = model self.model = model
detail = ( detail = (
f"No searchable fields found for model '{model.__name__}'. " f"No searchable fields found for model '{model.__name__}'. "

View File

@@ -12,6 +12,25 @@ from .exceptions import ApiException
def init_exceptions_handlers(app: FastAPI) -> FastAPI: 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) _register_exception_handlers(app)
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign] app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
return app return app

View File

@@ -1,3 +1,5 @@
"""Fixture system for seeding databases with dependency resolution."""
from .enum import LoadStrategy from .enum import LoadStrategy
from .registry import Context, FixtureRegistry from .registry import Context, FixtureRegistry
from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context

View File

@@ -1,3 +1,5 @@
"""Enums for fixture loading strategies and contexts."""
from enum import Enum from enum import Enum

View File

@@ -1,3 +1,5 @@
"""Fixture loading utilities for database seeding."""
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from typing import Any, TypeVar from typing import Any, TypeVar

View File

@@ -35,6 +35,12 @@ def configure_logging(
Returns: Returns:
The configured Logger instance. The configured Logger instance.
Example:
from fastapi_toolsets.logger import configure_logging
logger = configure_logging("DEBUG")
logger.info("Application started")
""" """
formatter = logging.Formatter(fmt) formatter = logging.Formatter(fmt)
@@ -75,6 +81,13 @@ def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[
Returns: Returns:
A Logger instance. 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: if name is _SENTINEL:
name = sys._getframe(1).f_globals.get("__name__") name = sys._getframe(1).f_globals.get("__name__")

View File

@@ -1,3 +1,5 @@
"""Pytest helpers for FastAPI testing: sessions, clients, and fixtures."""
from .plugin import register_fixtures from .plugin import register_fixtures
from .utils import ( from .utils import (
cleanup_tables, cleanup_tables,

View File

@@ -33,7 +33,6 @@ async def create_async_client(
An AsyncClient configured for the app. An AsyncClient configured for the app.
Example: Example:
```python
from fastapi import FastAPI from fastapi import FastAPI
from fastapi_toolsets.pytest import create_async_client from fastapi_toolsets.pytest import create_async_client
@@ -47,7 +46,6 @@ async def create_async_client(
async def test_endpoint(client: AsyncClient): async def test_endpoint(client: AsyncClient):
response = await client.get("/health") response = await client.get("/health")
assert response.status_code == 200 assert response.status_code == 200
```
""" """
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url=base_url) as client: 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. An AsyncSession ready for database operations.
Example: Example:
```python
from fastapi_toolsets.pytest import create_db_session from fastapi_toolsets.pytest import create_db_session
from app.models import Base from app.models import Base
@@ -94,7 +91,6 @@ async def create_db_session(
user = User(name="test") user = User(name="test")
db_session.add(user) db_session.add(user)
await db_session.commit() await db_session.commit()
```
""" """
engine = create_async_engine(database_url, echo=echo) 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. A database URL with a worker- or default-specific database name.
Example: Example:
```python
# With PYTEST_XDIST_WORKER="gw0": # With PYTEST_XDIST_WORKER="gw0":
url = worker_database_url( url = worker_database_url(
"postgresql+asyncpg://user:pass@localhost/test_db", "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", default_test_db="test",
) )
# "postgresql+asyncpg://user:pass@localhost/test_db_test" # "postgresql+asyncpg://user:pass@localhost/test_db_test"
```
""" """
worker = _get_xdist_worker(default_test_db=default_test_db) worker = _get_xdist_worker(default_test_db=default_test_db)
@@ -198,7 +192,6 @@ async def create_worker_database(
The worker-specific database URL. The worker-specific database URL.
Example: Example:
```python
from fastapi_toolsets.pytest import ( from fastapi_toolsets.pytest import (
create_worker_database, create_db_session, cleanup_tables 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: async with create_db_session(worker_db_url, Base) as session:
yield session yield session
await cleanup_tables(session, Base) await cleanup_tables(session, Base)
```
""" """
worker_url = worker_database_url( worker_url = worker_database_url(
database_url=database_url, default_test_db=default_test_db 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. base: SQLAlchemy DeclarativeBase class containing model metadata.
Example: Example:
```python
@pytest.fixture @pytest.fixture
async def db_session(worker_db_url): async def db_session(worker_db_url):
async with create_db_session(worker_db_url, Base) as session: async with create_db_session(worker_db_url, Base) as session:
yield session yield session
await cleanup_tables(session, Base) await cleanup_tables(session, Base)
```
""" """
tables = base.metadata.sorted_tables tables = base.metadata.sorted_tables
if not tables: if not tables: