mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-03-01 17:00:48 +01:00
doc: add missing docstring + add missing feature to README (#57)
This commit is contained in:
10
README.md
10
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Standardized API exceptions and error response handlers."""
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
ApiError,
|
ApiError,
|
||||||
ApiException,
|
ApiException,
|
||||||
|
|||||||
@@ -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__}'. "
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"""Enums for fixture loading strategies and contexts."""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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__")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user