Ir para o conteúdo

Referência da API

Gerada automaticamente a partir das docstrings do SDK via mkdocstrings. Todo símbolo público exportado por tempest_fastapi_sdk está documentado aqui com sua assinatura completa, parâmetros, tipo de retorno, exceções levantadas e localização no código-fonte.

Buscando

Use a barra de busca no topo da página (ou pressione /) para pular para um símbolo pelo nome. O índice full-text inclui as docstrings, então buscas como "soft delete" ou "request id" caem na classe certa.


Superfície de topo

tempest_fastapi_sdk

tempest-fastapi-sdk — shared FastAPI/SQLAlchemy/Pydantic primitives.

FieldRef module-attribute

FieldRef = InstrumentedAttribute[Any] | str

A column reference: either a mapped attribute (Model.email) or its string key ("email"). Column references give editor autocomplete and typo-checking; strings remain accepted for dynamic configuration.

OrderRef module-attribute

OrderRef = InstrumentedAttribute[Any] | UnaryExpression[Any] | str

An ordering reference: a column (Model.created_at, ascending), a direction-wrapped column (desc(Model.created_at)), or a Django-style string ("created_at" / "-created_at").

CSRF_COOKIE_NAME: str = 'csrf_token'

Default cookie holding the CSRF token.

CSRF_HEADER_NAME module-attribute

CSRF_HEADER_NAME: str = 'X-CSRF-Token'

Default header the client echoes the cookie value into.

HealthCheck module-attribute

HealthCheck = Callable[[], Awaitable[bool]]

Type alias for async health-check callables.

Each callable returns True when the dependency is healthy and False (or raises) otherwise. Exceptions are caught by the readiness endpoint and translated to False.

LogSource module-attribute

LogSource = Literal['all', 'debug', 'info', 'warning', 'error', 'critical', '500']

Selectable log source for the /logs endpoint.

"all" merges every per-level file (excluding 500.log to avoid duplicating error.log rows); the rest map to a single file.

BASE_COLUMN_ORDER module-attribute

BASE_COLUMN_ORDER: tuple[str, ...] = ('id', 'is_active', 'created_at', 'updated_at')

Columns inherited from BaseModel — emitted in this exact order at the top of every op.create_table produced by autogenerate.

NAMING_CONVENTION module-attribute

NAMING_CONVENTION: dict[str, str] = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}

Alembic-friendly naming convention applied to every constraint.

Constraint names become deterministic across machines and DB engines, so alembic revision --autogenerate only emits real schema diffs instead of churn from auto-generated identifiers.

CEP module-attribute

CEP = Annotated[str, AfterValidator(normalize_cep)]

Pydantic type that validates a Brazilian CEP and normalizes to 8 digits.

CEP_PATTERN module-attribute

CEP_PATTERN: Final[Pattern[str]] = compile('^\\d{5}-?\\d{3}$')

Match a Brazilian CEP in either masked (00000-000) or raw form.

CNPJ module-attribute

CNPJ = Annotated[str, AfterValidator(normalize_cnpj)]

Pydantic type that validates and normalizes a CNPJ to 14 digits.

CNPJ_PATTERN module-attribute

CNPJ_PATTERN: Final[Pattern[str]] = compile(
    "^\\d{2}\\.?\\d{3}\\.?\\d{3}/?\\d{4}-?\\d{2}$"
)

Match a CNPJ in either masked (00.000.000/0000-00) or raw form.

CPF module-attribute

CPF = Annotated[str, AfterValidator(normalize_cpf)]

Pydantic type that validates and normalizes a CPF to 11 digits.

CPF_CNPJ_PATTERN module-attribute

CPF_CNPJ_PATTERN: Final[Pattern[str]] = compile(
    f"(?:{pattern[1:(-1)]})|(?:{pattern[1:(-1)]})"
)

Match either a CPF or a CNPJ (masked or raw).

CPF_PATTERN module-attribute

CPF_PATTERN: Final[Pattern[str]] = compile('^\\d{3}\\.?\\d{3}\\.?\\d{3}-?\\d{2}$')

Match a CPF in either masked (000.000.000-00) or raw form.

PHONE_BR_PATTERN module-attribute

PHONE_BR_PATTERN: Final[Pattern[str]] = compile(
    "^(?:\\+?55\\s?)?(?:\\(?\\d{2}\\)?\\s?)?9?\\d{4}[-\\s]?\\d{4}$"
)

Match a BR phone number with optional +55, DDD, mask or 9th digit.

REQUEST_ID_HEADER module-attribute

REQUEST_ID_HEADER: str = 'X-Request-ID'

Outbound header carrying the inbound correlation id.

CPFOrCNPJ module-attribute

CPFOrCNPJ = Annotated[str, AfterValidator(normalize_cpf_cnpj)]

Pydantic type that accepts either a CPF or a CNPJ.

PhoneBR module-attribute

PhoneBR = Annotated[str, AfterValidator(normalize_phone_br)]

Pydantic type that validates a BR phone and normalizes to digits.

AdminAuthBackend

Bases: ABC

Abstract base for admin authentication.

Implementations receive a session-bound async DB session per login attempt; the default :class:UserModelAuthBackend queries a :class:BaseUserModel subclass and enforces is_admin=True. Custom backends can use the same protocol to integrate LDAP, OAuth, IAM tokens, etc.

AdminAuthError

AdminAuthError(message: str = 'Invalid credentials', *, status_code: int = 401)

Bases: Exception

Raised by authentication backends when credentials are rejected.

Captures the user-facing message the login template should render alongside the HTTP status code (default 401). Specific failure reasons should map to subclasses of this base exception for granular templating.

Initialize the error.

Parameters:

Name Type Description Default
message str

The end-user-facing message.

'Invalid credentials'
status_code int

HTTP status code to attach.

401
Source code in tempest_fastapi_sdk/admin/auth.py
def __init__(
    self,
    message: str = "Invalid credentials",
    *,
    status_code: int = 401,
) -> None:
    """Initialize the error.

    Args:
        message (str): The end-user-facing message.
        status_code (int): HTTP status code to attach.
    """
    super().__init__(message)
    self.message: str = message
    self.status_code: int = status_code

AdminModel

AdminModel(
    model: type[ModelT],
    *,
    list_display: Sequence[FieldRef] | None = None,
    list_filter: Sequence[FieldRef] = (),
    search_fields: Sequence[FieldRef] = (),
    readonly_fields: Sequence[FieldRef] = (),
    ordering: OrderRef | None = None,
    page_size: int = 25,
    identity_field: FieldRef = "id",
    repository_class: type[BaseRepository[Any]] | None = None,
    verbose_name: str | None = None,
    verbose_name_plural: str | None = None,
)

Bases: Generic[ModelT]

Declarative admin configuration for one SQLAlchemy model.

Instantiate once per managed model and pass it to :meth:AdminSite.register. Unlike Django's class-based ModelAdmin, this is a plain typed instance — the constructor signature is the contract, fields accept real SQLAlchemy column attributes (so typos surface in the editor, not at runtime), and there is no metaclass magic::

site.register(AdminModel(
    model=UserModel,
    list_display=[UserModel.email, UserModel.is_admin],
    search_fields=[UserModel.email],
    ordering=desc(UserModel.created_at),
))

Parameters:

Name Type Description Default
model type[ModelT]

The SQLAlchemy model class.

required
list_display Sequence[FieldRef] | None

Columns shown in the list view. None defaults to every column except the password hash.

None
list_filter Sequence[FieldRef]

Fields surfaced as filter dropdowns; matched via the repository's standard filter pipeline.

()
search_fields Sequence[FieldRef]

String columns searched with ILIKE %value% via the repository's name convention.

()
readonly_fields Sequence[FieldRef]

Fields locked in the detail view.

()
ordering OrderRef | None

Default ordering. Accepts a column (ascending), desc(column) / asc(column), or a string column name with an optional leading - for descending. None falls back to created_at descending.

None
page_size int

Default rows per page in the list view.

25
identity_field FieldRef

Column used to look up a single row from the detail URL. Defaults to "id" (UUID PK).

'id'
repository_class type[BaseRepository[Any]] | None

Concrete repository. None synthesizes an anonymous repository bound to :attr:model.

None
verbose_name str | None

Singular display name; defaults to the model name humanized.

None
verbose_name_plural str | None

Plural display name; defaults to verbose_name + "s".

None

Raises:

Type Description
TypeError

When model is not a subclass of :class:BaseModel, or when a field reference cannot be resolved to a column key.

Build and validate the configuration. See class docstring.

Source code in tempest_fastapi_sdk/admin/config.py
def __init__(
    self,
    model: type[ModelT],
    *,
    list_display: Sequence[FieldRef] | None = None,
    list_filter: Sequence[FieldRef] = (),
    search_fields: Sequence[FieldRef] = (),
    readonly_fields: Sequence[FieldRef] = (),
    ordering: OrderRef | None = None,
    page_size: int = 25,
    identity_field: FieldRef = "id",
    repository_class: type[BaseRepository[Any]] | None = None,
    verbose_name: str | None = None,
    verbose_name_plural: str | None = None,
) -> None:
    """Build and validate the configuration. See class docstring."""
    if not isinstance(model, type) or not issubclass(model, BaseModel):
        raise TypeError("AdminModel `model` must be a subclass of BaseModel")

    self.model: type[ModelT] = model
    self.list_display: list[str] | None = (
        None if list_display is None else _normalize_fields(list_display)
    )
    self.list_filter: list[str] = _normalize_fields(list_filter)
    self.search_fields: list[str] = _normalize_fields(search_fields)
    self.readonly_fields: list[str] = _normalize_fields(readonly_fields)
    self.order_key: str | None
    self.order_ascending: bool
    self.order_key, self.order_ascending = _normalize_ordering(ordering)
    self.page_size: int = page_size
    self.identity_field: str = _field_key(identity_field)
    self.repository_class: type[BaseRepository[Any]] | None = repository_class
    self.verbose_name: str | None = verbose_name
    self.verbose_name_plural: str | None = verbose_name_plural

AdminSite

AdminSite(
    title: str = "Admin",
    *,
    index_subtitle: str = "Site administration",
    site_url: str | None = None,
)

Holds the set of :class:AdminModel configurations to expose.

Each project instantiates one site, registers its admin configurations, and passes the site to :func:make_admin_router. Sites are explicit (no auto-discovery) so the surface remains predictable across deployments.

Attributes:

Name Type Description
title str

Branding shown at the top of every admin page.

index_subtitle str

Optional subtitle for the dashboard.

site_url str | None

Optional "View site" link rendered in the admin header.

Initialize the site.

Parameters:

Name Type Description Default
title str

Branding text.

'Admin'
index_subtitle str

Dashboard subtitle.

'Site administration'
site_url str | None

Optional outbound link rendered in the admin header.

None
Source code in tempest_fastapi_sdk/admin/site.py
def __init__(
    self,
    title: str = "Admin",
    *,
    index_subtitle: str = "Site administration",
    site_url: str | None = None,
) -> None:
    """Initialize the site.

    Args:
        title (str): Branding text.
        index_subtitle (str): Dashboard subtitle.
        site_url (str | None): Optional outbound link rendered
            in the admin header.
    """
    self.title: str = title
    self.index_subtitle: str = index_subtitle
    self.site_url: str | None = site_url
    self._registry: dict[str, AdminModel[Any]] = {}

UserModelAuthBackend

UserModelAuthBackend(user_model: type[BaseUserModel])

Bases: AdminAuthBackend

Default backend backed by :class:BaseUserModel.

Authenticates by selecting the row whose email matches the inbound identifier (case-insensitive), verifying the password via :class:tempest_fastapi_sdk.PasswordUtils and enforcing both is_admin=True and is_active=True. The :attr:last_login_at column is stamped on every successful login.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

The concrete model class. Must be a subclass of :class:BaseUserModel.

required

Initialize the backend.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

The user model to query.

required

Raises:

Type Description
TypeError

When user_model is not a subclass of :class:BaseUserModel.

Source code in tempest_fastapi_sdk/admin/auth.py
def __init__(self, user_model: type[BaseUserModel]) -> None:
    """Initialize the backend.

    Args:
        user_model (type[BaseUserModel]): The user model to query.

    Raises:
        TypeError: When ``user_model`` is not a subclass of
            :class:`BaseUserModel`.
    """
    if not isinstance(user_model, type) or not issubclass(
        user_model, BaseUserModel
    ):
        raise TypeError(
            "user_model must be a subclass of BaseUserModel",
        )
    self.user_model: type[BaseUserModel] = user_model

BodySizeLimitMiddleware

BodySizeLimitMiddleware(
    app: ASGIApp, *, max_bytes: int, exclude_paths: tuple[str, ...] = ()
)

Pure ASGI middleware enforcing max_bytes per request.

Two checks happen:

  1. Header checkContent-Length greater than the cap short-circuits immediately with a 413 response. This catches the common case where the client knows the size.
  2. Streaming check — for chunked / unknown-length uploads the middleware tracks bytes seen in the http.request messages and aborts once the cap is crossed.

Excluded paths bypass the check entirely (typical use: an upload endpoint that intentionally accepts larger bodies and enforces its own per-route limit).

Initialize.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI app.

required
max_bytes int

Hard cap on the request body in bytes. 0 disables the check (do not ship to production).

required
exclude_paths tuple[str, ...]

Path prefixes that bypass the limit. Match is startswith so the more specific the better.

()
Source code in tempest_fastapi_sdk/api/middlewares/body_size.py
def __init__(
    self,
    app: ASGIApp,
    *,
    max_bytes: int,
    exclude_paths: tuple[str, ...] = (),
) -> None:
    """Initialize.

    Args:
        app (ASGIApp): The wrapped ASGI app.
        max_bytes (int): Hard cap on the request body in bytes.
            ``0`` disables the check (do not ship to production).
        exclude_paths (tuple[str, ...]): Path prefixes that
            bypass the limit. Match is ``startswith`` so the
            more specific the better.
    """
    self.app: ASGIApp = app
    self.max_bytes: int = max_bytes
    self.exclude_paths: tuple[str, ...] = exclude_paths

CachedResponse dataclass

CachedResponse(
    status_code: int,
    headers: list[tuple[str, str]],
    body: bytes,
    media_type: str | None,
)

Serialized response stored under an idempotency key.

Attributes:

Name Type Description
status_code int

HTTP status of the original response.

headers list[tuple[str, str]]

Response headers as a flat list of (name, value) pairs (preserving duplicates for Set-Cookie).

body bytes

Raw response body bytes.

media_type str | None

Original Content-Type.

CSRFMiddleware

CSRFMiddleware(
    app: ASGIApp,
    *,
    cookie_name: str = CSRF_COOKIE_NAME,
    header_name: str = CSRF_HEADER_NAME,
    exclude_paths: tuple[str, ...] = (),
)

Bases: BaseHTTPMiddleware

Double-submit cookie CSRF guard.

On unsafe methods (POST / PUT / PATCH / DELETE) the request MUST carry:

  1. The CSRF cookie (csrf_token by default).
  2. The CSRF header (X-CSRF-Token by default) with the same value.

Missing or mismatched values return 403 with the SDK envelope. Excluded paths bypass the check — typical use: /api/ routes that use Authorization: Bearer (not susceptible to CSRF), or webhook callbacks whose authentication is signature-based.

Safe methods (GET / HEAD / OPTIONS) always pass.

Initialize.

Parameters:

Name Type Description Default
app ASGIApp

Wrapped app.

required
cookie_name str

Name of the CSRF cookie.

CSRF_COOKIE_NAME
header_name str

Name of the CSRF header.

CSRF_HEADER_NAME
exclude_paths tuple[str, ...]

Path prefixes that bypass the check (e.g. ("/api/", "/webhooks/")).

()
Source code in tempest_fastapi_sdk/api/middlewares/csrf.py
def __init__(
    self,
    app: ASGIApp,
    *,
    cookie_name: str = CSRF_COOKIE_NAME,
    header_name: str = CSRF_HEADER_NAME,
    exclude_paths: tuple[str, ...] = (),
) -> None:
    """Initialize.

    Args:
        app (ASGIApp): Wrapped app.
        cookie_name (str): Name of the CSRF cookie.
        header_name (str): Name of the CSRF header.
        exclude_paths (tuple[str, ...]): Path prefixes that
            bypass the check (e.g. ``("/api/", "/webhooks/")``).
    """
    super().__init__(app)
    self.cookie_name: str = cookie_name
    self.header_name: str = header_name
    self.exclude_paths: tuple[str, ...] = exclude_paths

GitHubOAuthClient

GitHubOAuthClient(
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
)

Bases: _BaseOAuthClient

GitHub OAuth client.

GitHub doesn't issue an id_token — the user identity comes from GET /user. Default scopes: read:user user:email.

Source code in tempest_fastapi_sdk/api/oauth.py
def __init__(
    self,
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
) -> None:
    """Initialize.

    Args:
        client_id (str): App client id issued by the provider.
        client_secret (str): App client secret.
        redirect_uri (str): Callback URL registered with the
            provider; must match exactly.
        scopes (list[str] | None): Scopes to request. Provider
            subclasses ship sensible defaults.
        http_client (HTTPClient | None): Shared client to
            reuse. ``None`` builds a dedicated one with sane
            defaults.
    """
    self.client_id: str = client_id
    self.client_secret: str = client_secret
    self.redirect_uri: str = redirect_uri
    self.scopes: list[str] = scopes or self._default_scopes()
    self._http: HTTPClient = http_client or HTTPClient(
        timeout=10.0,
        failure_threshold=0,
    )
    self._owns_http: bool = http_client is None

GoogleOAuthClient

GoogleOAuthClient(
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
)

Bases: _BaseOAuthClient

Google identity client (OIDC-compatible).

Default scopes: openid email profile.

Source code in tempest_fastapi_sdk/api/oauth.py
def __init__(
    self,
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
) -> None:
    """Initialize.

    Args:
        client_id (str): App client id issued by the provider.
        client_secret (str): App client secret.
        redirect_uri (str): Callback URL registered with the
            provider; must match exactly.
        scopes (list[str] | None): Scopes to request. Provider
            subclasses ship sensible defaults.
        http_client (HTTPClient | None): Shared client to
            reuse. ``None`` builds a dedicated one with sane
            defaults.
    """
    self.client_id: str = client_id
    self.client_secret: str = client_secret
    self.redirect_uri: str = redirect_uri
    self.scopes: list[str] = scopes or self._default_scopes()
    self._http: HTTPClient = http_client or HTTPClient(
        timeout=10.0,
        failure_threshold=0,
    )
    self._owns_http: bool = http_client is None

HardenedStaticFiles

HardenedStaticFiles(
    *args: object, security_headers: dict[str, str] | None = None, **kwargs: object
)

Bases: StaticFiles

StaticFiles that stamps anti-XSS headers on every response.

Defense in depth for serving user-uploaded content: if a malicious file ever lands on disk (an upload-validation bypass, a manual operator action), serving it does not become a stored-XSS primitive against any same-origin SPA.

Use exactly like :class:starlette.staticfiles.StaticFilesapp.mount("/uploads", HardenedStaticFiles(directory=...)). Pass security_headers= to override or extend the defaults (:data:DEFAULT_STATIC_SECURITY_HEADERS). Existing headers set by the parent are preserved (setdefault semantics).

Initialize.

Parameters:

Name Type Description Default
*args object

Positional arguments forwarded to StaticFiles.

()
security_headers dict[str, str] | None

Headers to stamp on every response. Defaults to :data:DEFAULT_STATIC_SECURITY_HEADERS.

None
**kwargs object

Keyword arguments forwarded to StaticFiles (directory, packages, html, check_dir …).

{}
Source code in tempest_fastapi_sdk/api/static.py
def __init__(
    self,
    *args: object,
    security_headers: dict[str, str] | None = None,
    **kwargs: object,
) -> None:
    """Initialize.

    Args:
        *args: Positional arguments forwarded to ``StaticFiles``.
        security_headers (dict[str, str] | None): Headers to stamp
            on every response. Defaults to
            :data:`DEFAULT_STATIC_SECURITY_HEADERS`.
        **kwargs: Keyword arguments forwarded to ``StaticFiles``
            (``directory``, ``packages``, ``html``, ``check_dir`` …).
    """
    super().__init__(*args, **kwargs)  # type: ignore[arg-type]
    self.security_headers: dict[str, str] = (
        dict(security_headers)
        if security_headers is not None
        else dict(DEFAULT_STATIC_SECURITY_HEADERS)
    )

IdempotencyMiddleware

IdempotencyMiddleware(
    app: ASGIApp,
    *,
    store: IdempotencyStore,
    ttl_seconds: int = 24 * 3600,
    header_name: str = IDEMPOTENCY_HEADER,
)

Bases: BaseHTTPMiddleware

ASGI middleware caching responses by Idempotency-Key.

Only mutating verbs (POST / PUT / PATCH / DELETE) are eligible. The key is scoped per (method, path, key) so a key reused across different endpoints doesn't collide.

Add to FastAPI like any other ASGI middleware:

from tempest_fastapi_sdk import (
    IdempotencyMiddleware,
    MemoryIdempotencyStore,
)

app.add_middleware(
    IdempotencyMiddleware,
    store=MemoryIdempotencyStore(),
    ttl_seconds=24 * 3600,
)

Initialize the middleware.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI app.

required
store IdempotencyStore

Backend used to cache responses. Pass :class:MemoryIdempotencyStore for single-replica deployments, :class:RedisIdempotencyStore otherwise.

required
ttl_seconds int

How long to keep cached responses. Stripe defaults to 24 hours — long enough to cover client retries with exponential backoff.

24 * 3600
header_name str

Header carrying the idempotency key. Defaults to the canonical Idempotency-Key.

IDEMPOTENCY_HEADER
Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
def __init__(
    self,
    app: ASGIApp,
    *,
    store: IdempotencyStore,
    ttl_seconds: int = 24 * 3600,
    header_name: str = IDEMPOTENCY_HEADER,
) -> None:
    """Initialize the middleware.

    Args:
        app (ASGIApp): The wrapped ASGI app.
        store (IdempotencyStore): Backend used to cache responses.
            Pass :class:`MemoryIdempotencyStore` for single-replica
            deployments, :class:`RedisIdempotencyStore` otherwise.
        ttl_seconds (int): How long to keep cached responses.
            Stripe defaults to 24 hours — long enough to cover
            client retries with exponential backoff.
        header_name (str): Header carrying the idempotency key.
            Defaults to the canonical ``Idempotency-Key``.
    """
    super().__init__(app)
    self.store: IdempotencyStore = store
    self.ttl_seconds: int = ttl_seconds
    self.header_name: str = header_name

IdempotencyStore

Bases: Protocol

Protocol every idempotency cache implements.

MemoryIdempotencyStore

MemoryIdempotencyStore()

In-process :class:IdempotencyStore with TTL eviction.

Single-replica only — a second replica won't see entries stored by the first. Suitable for dev, tests, and small services that haven't scaled out yet.

The eviction is best-effort: TTLs are checked on access; no background thread cleans the dict. Memory grows linearly with cached requests until they expire, so set a sensible TTL.

Initialize the in-memory store.

Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
def __init__(self) -> None:
    """Initialize the in-memory store."""
    self._store: dict[str, tuple[float, CachedResponse]] = {}
    self._lock: asyncio.Lock = asyncio.Lock()

OAuthError

OAuthError(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when an OAuth exchange fails — wraps the IdP message.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

OAuthTokens dataclass

OAuthTokens(
    access_token: str,
    token_type: str,
    refresh_token: str | None = None,
    id_token: str | None = None,
    expires_in: int | None = None,
    scope: str | None = None,
    raw: dict[str, Any] = dict(),
)

Tokens returned by the IdP after the authorization-code exchange.

Attributes:

Name Type Description
access_token str

Bearer token to call provider APIs.

token_type str

Usually "Bearer".

refresh_token str | None

Refresh token when offline access was requested.

id_token str | None

OIDC id token (JWT). Present on OIDC flows, absent on plain OAuth2.

expires_in int | None

Lifetime of access_token in seconds.

scope str | None

Space-separated scopes granted.

raw dict[str, Any]

Full token-endpoint response.

OAuthUser dataclass

OAuthUser(
    provider: str,
    subject: str,
    email: str | None = None,
    name: str | None = None,
    picture: str | None = None,
    raw: dict[str, Any] = dict(),
)

Normalized user identity returned by every provider.

Different IdPs use different field names (sub vs id, picture vs avatar_url, name vs login). This dataclass is the single shape the rest of the application sees.

Attributes:

Name Type Description
provider str

Provider key ("google", "github", "oidc:auth0" …). Useful when multiple providers feed the same user table.

subject str

Stable per-provider user id. Combine with provider for a globally-unique key.

email str | None

Verified email when the provider returned one. Some IdPs gate this behind extra scopes.

name str | None

Human-readable display name.

picture str | None

Avatar / profile picture URL.

raw dict[str, Any]

Full provider payload for advanced cases (custom claims, role mappings).

OIDCProvider

OIDCProvider(
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    authorize_url: str,
    token_url: str,
    userinfo_url: str | None = None,
    provider_name: str = "oidc",
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
)

Bases: _BaseOAuthClient

Generic OIDC provider — works with any conformant IdP.

Pass the authorize / token / userinfo endpoints explicitly, or fetch them once at boot from the IdP's discovery document at ${issuer}/.well-known/openid-configuration and pass the URLs in. Default scopes: openid email profile.

Initialize.

Parameters:

Name Type Description Default
client_id str

App client id at the IdP.

required
client_secret str

App client secret.

required
redirect_uri str

Registered callback URL.

required
authorize_url str

IdP's authorize endpoint.

required
token_url str

IdP's token endpoint.

required
userinfo_url str | None

IdP's userinfo endpoint. None requires you to override :meth:_parse_user to read claims from the id_token.

None
provider_name str

Key embedded in :attr:OAuthUser.provider (e.g. "oidc:auth0").

'oidc'
scopes list[str] | None

Scopes to request.

None
http_client HTTPClient | None

Shared client.

None
Source code in tempest_fastapi_sdk/api/oauth.py
def __init__(
    self,
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    authorize_url: str,
    token_url: str,
    userinfo_url: str | None = None,
    provider_name: str = "oidc",
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
) -> None:
    """Initialize.

    Args:
        client_id (str): App client id at the IdP.
        client_secret (str): App client secret.
        redirect_uri (str): Registered callback URL.
        authorize_url (str): IdP's authorize endpoint.
        token_url (str): IdP's token endpoint.
        userinfo_url (str | None): IdP's userinfo endpoint.
            ``None`` requires you to override
            :meth:`_parse_user` to read claims from the
            ``id_token``.
        provider_name (str): Key embedded in
            :attr:`OAuthUser.provider` (e.g. ``"oidc:auth0"``).
        scopes (list[str] | None): Scopes to request.
        http_client (HTTPClient | None): Shared client.
    """
    self._authorize_url: str = authorize_url
    self._token_url: str = token_url
    self._userinfo_url: str | None = userinfo_url
    self.provider_name = provider_name
    super().__init__(
        client_id=client_id,
        client_secret=client_secret,
        redirect_uri=redirect_uri,
        scopes=scopes,
        http_client=http_client,
    )

PrometheusMiddleware

PrometheusMiddleware(
    app: ASGIApp,
    *,
    registry: CollectorRegistry,
    latency_buckets: tuple[float, ...] = DEFAULT_LATENCY_BUCKETS,
)

Bases: BaseHTTPMiddleware

ASGI middleware tracking HTTP requests on three core metrics.

Registered series:

  • http_requests_total{method, path, status} (Counter) — every request counts here once the response status is known.
  • http_request_duration_seconds{method, path} (Histogram) — end-to-end latency in seconds.
  • http_requests_in_progress{method} (Gauge) — live inflight count, decremented in a finally so dropped connections never leave stale gauges.

The path label uses the route template (e.g. /orders/{order_id}) when the request hit a FastAPI route, not the raw URL — that keeps the cardinality bounded.

Initialize the middleware.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI app.

required
registry CollectorRegistry

Shared registry. Reuse the same instance for make_prometheus_router so the /metrics endpoint scrapes these series.

required
latency_buckets tuple[float, ...]

Histogram bucket upper bounds in seconds.

DEFAULT_LATENCY_BUCKETS

Raises:

Type Description
ImportError

When the [prometheus] extra is missing.

Source code in tempest_fastapi_sdk/api/routers/metrics.py
def __init__(
    self,
    app: ASGIApp,
    *,
    registry: CollectorRegistry,
    latency_buckets: tuple[float, ...] = DEFAULT_LATENCY_BUCKETS,
) -> None:
    """Initialize the middleware.

    Args:
        app (ASGIApp): The wrapped ASGI app.
        registry (CollectorRegistry): Shared registry. Reuse the
            same instance for ``make_prometheus_router`` so the
            ``/metrics`` endpoint scrapes these series.
        latency_buckets (tuple[float, ...]): Histogram bucket
            upper bounds in seconds.

    Raises:
        ImportError: When the ``[prometheus]`` extra is missing.
    """
    _require_prometheus()
    super().__init__(app)
    self.requests_total: Counter = Counter(
        "http_requests_total",
        "HTTP requests by method, route template, and response status.",
        labelnames=("method", "path", "status"),
        registry=registry,
    )
    self.request_duration: Histogram = Histogram(
        "http_request_duration_seconds",
        "HTTP request latency by method and route template (seconds).",
        labelnames=("method", "path"),
        buckets=latency_buckets,
        registry=registry,
    )
    self.in_progress: Gauge = Gauge(
        "http_requests_in_progress",
        "HTTP requests currently being handled.",
        labelnames=("method",),
        registry=registry,
    )

RateLimitMiddleware

RateLimitMiddleware(
    app: ASGIApp,
    *,
    max_requests: int = 60,
    window_seconds: float = 60.0,
    key_func: Callable[[Request], str] | None = None,
    trusted_ip_header: str | None = None,
    exempt_paths: tuple[str, ...] = (),
    retry_after_header: bool = True,
    error_message: str = "Too many requests",
)

Bases: BaseHTTPMiddleware

Lightweight in-process sliding-window rate limiter.

Each unique key (by default the client IP) is allowed at most max_requests requests inside every window_seconds window. Excess requests are rejected with 429 Too Many Requests and a Retry-After header. State is held in-process — for multi-worker deployments, share state via a Redis-backed limiter outside the SDK or run the limiter behind a single reverse-proxy worker.

Running behind a proxy: the default key is the direct transport peer, which is the proxy IP once a reverse proxy fronts the app — so every client collapses into one bucket. Pass trusted_ip_header (e.g. "x-real-ip") naming a header your edge sets from its own connection (never a client-supplied X-Forwarded-For, which is spoofable) so the limit is per real client. See :func:tempest_fastapi_sdk.utils.get_client_ip.

Attributes:

Name Type Description
max_requests int

Maximum requests inside the window.

window_seconds float

Length of the sliding window.

Initialize the middleware.

Parameters:

Name Type Description Default
app ASGIApp

The underlying ASGI app.

required
max_requests int

Maximum requests per window.

60
window_seconds float

Window length in seconds.

60.0
key_func Callable[[Request], str] | None

Build a rate-limit key from the request. Overrides trusted_ip_header. Defaults to the resolved client IP.

None
trusted_ip_header str | None

When set (and key_func is not), the rate-limit key is the client IP resolved from this single edge-set header (e.g. "x-real-ip"), falling back to the transport peer. None keys on the transport peer only — correct only when the app is not behind a proxy.

None
exempt_paths tuple[str, ...]

Paths to skip entirely (e.g. ("/health/liveness", "/health/readiness")).

()
retry_after_header bool

Whether to add a Retry-After header on 429 responses.

True
error_message str

Body of the 429 response.

'Too many requests'
Source code in tempest_fastapi_sdk/api/middlewares/rate_limit.py
def __init__(
    self,
    app: ASGIApp,
    *,
    max_requests: int = 60,
    window_seconds: float = 60.0,
    key_func: Callable[[Request], str] | None = None,
    trusted_ip_header: str | None = None,
    exempt_paths: tuple[str, ...] = (),
    retry_after_header: bool = True,
    error_message: str = "Too many requests",
) -> None:
    """Initialize the middleware.

    Args:
        app (ASGIApp): The underlying ASGI app.
        max_requests (int): Maximum requests per window.
        window_seconds (float): Window length in seconds.
        key_func (Callable[[Request], str] | None): Build a
            rate-limit key from the request. Overrides
            ``trusted_ip_header``. Defaults to the resolved client
            IP.
        trusted_ip_header (str | None): When set (and ``key_func``
            is not), the rate-limit key is the client IP resolved
            from this single edge-set header (e.g. ``"x-real-ip"``),
            falling back to the transport peer. ``None`` keys on the
            transport peer only — correct only when the app is not
            behind a proxy.
        exempt_paths (tuple[str, ...]): Paths to skip entirely
            (e.g. ``("/health/liveness", "/health/readiness")``).
        retry_after_header (bool): Whether to add a
            ``Retry-After`` header on 429 responses.
        error_message (str): Body of the 429 response.
    """
    super().__init__(app)
    if max_requests < 1:
        raise ValueError("max_requests must be >= 1")
    if window_seconds <= 0:
        raise ValueError("window_seconds must be > 0")
    self.max_requests: int = max_requests
    self.window_seconds: float = window_seconds
    if key_func is not None:
        self._key_func: Callable[[Request], str] = key_func
    else:
        self._key_func = lambda request: get_client_ip(
            request,
            trusted_header=trusted_ip_header,
        )
    self._exempt: frozenset[str] = frozenset(exempt_paths)
    self._retry_after_header: bool = retry_after_header
    self._error_message: str = error_message
    self._buckets: dict[str, deque[float]] = {}
    self._lock: asyncio.Lock = asyncio.Lock()

RedisIdempotencyStore

RedisIdempotencyStore(client: _RedisLike, *, prefix: str = 'idem:')

:class:IdempotencyStore backed by an async redis client.

The cached payload is encoded as JSON so the schema stays portable across SDK versions: {"status_code", "headers", "body_b64", "media_type"} with the body base64-encoded because Redis values are bytes.

Use this in production / multi-replica deployments. Requires the [cache] extra so the redis async client is available.

Initialize.

Parameters:

Name Type Description Default
client _RedisLike

Async Redis-like client exposing get(key) / set(key, value, ex) (e.g. redis.asyncio.Redis or any equivalent).

required
prefix str

Key prefix so idempotency entries don't collide with other cached data.

'idem:'
Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
def __init__(
    self,
    client: _RedisLike,
    *,
    prefix: str = "idem:",
) -> None:
    """Initialize.

    Args:
        client (_RedisLike): Async Redis-like client exposing
            ``get(key)`` / ``set(key, value, ex)`` (e.g.
            ``redis.asyncio.Redis`` or any equivalent).
        prefix (str): Key prefix so idempotency entries don't
            collide with other cached data.
    """
    self.client: _RedisLike = client
    self.prefix: str = prefix

RequestIDMiddleware

RequestIDMiddleware(app: ASGIApp, header_name: str = 'X-Request-ID')

Bases: BaseHTTPMiddleware

Bind an X-Request-ID header to the request-scoped context.

Reads the inbound header (or generates a fresh UUID v4 when absent), stores it via :func:set_request_id so log records written during the request carry the request_id field, and echoes the same value back on the response so callers can trace end-to-end across services.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI application.

required
header_name str

The header to read/write. Defaults to "X-Request-ID".

'X-Request-ID'
Source code in tempest_fastapi_sdk/api/middlewares/request_id.py
def __init__(
    self,
    app: ASGIApp,
    header_name: str = "X-Request-ID",
) -> None:
    super().__init__(app)
    self.header_name: str = header_name

RSAWebhookSignatureVerifier

RSAWebhookSignatureVerifier(
    public_key_pem: str | bytes,
    *,
    algorithm: str = "sha256",
    header_name: str = "x-webhook-signature",
)

Validate RSA-signed webhook payloads (OpenPix/Woovi-style).

Some providers sign each webhook with their PRIVATE key and publish a well-known PUBLIC key; the receiver verifies the asymmetric signature over the raw body. This complements :class:WebhookSignatureVerifier (symmetric HMAC) for those gateways. Uses RSASSA-PKCS1-v1_5 over a configurable hash.

Requires cryptography (ships with the [webpush] extra, or pip install cryptography); the import is deferred to first use.

Attributes:

Name Type Description
public_key_pem bytes

The PEM-encoded provider public key.

algorithm str

Hash algorithm name ("sha256" default).

header_name str

Header carrying the base64 signature.

Initialize the verifier.

Parameters:

Name Type Description Default
public_key_pem str | bytes

PEM-encoded RSA public key. Strings are encoded as UTF-8.

required
algorithm str

Hash algorithm — "sha256" (default), "sha384" or "sha512".

'sha256'
header_name str

Header carrying the base64 signature.

'x-webhook-signature'

Raises:

Type Description
ValueError

If algorithm is not a supported SHA-2 hash.

Source code in tempest_fastapi_sdk/api/webhooks.py
def __init__(
    self,
    public_key_pem: str | bytes,
    *,
    algorithm: str = "sha256",
    header_name: str = "x-webhook-signature",
) -> None:
    """Initialize the verifier.

    Args:
        public_key_pem (str | bytes): PEM-encoded RSA public key.
            Strings are encoded as UTF-8.
        algorithm (str): Hash algorithm — ``"sha256"`` (default),
            ``"sha384"`` or ``"sha512"``.
        header_name (str): Header carrying the base64 signature.

    Raises:
        ValueError: If ``algorithm`` is not a supported SHA-2 hash.
    """
    if algorithm not in {"sha256", "sha384", "sha512"}:
        raise ValueError(
            "algorithm must be one of sha256/sha384/sha512",
        )
    self.public_key_pem: bytes = (
        public_key_pem.encode("utf-8")
        if isinstance(public_key_pem, str)
        else public_key_pem
    )
    self.algorithm: str = algorithm
    self.header_name: str = header_name

WebhookSignatureVerifier

WebhookSignatureVerifier(
    secret: str | bytes,
    *,
    algorithm: str = "sha256",
    header_name: str = "X-Signature",
    encoding: str = "hex",
    prefix: str = "",
)

Validate HMAC-signed webhook payloads (Stripe/GitHub-style).

Providers usually compute hmac(secret, body) with a fixed algorithm and ship the digest in a request header (hex or base64). This helper centralizes the verification using :func:hmac.compare_digest (constant time) and exposes a FastAPI dependency that reads the raw body without consuming it for the route handler.

Attributes:

Name Type Description
secret bytes

The shared secret as bytes.

algorithm str

The hashlib algorithm name.

header_name str

The request header carrying the signature.

encoding str

How the signature is encoded — "hex" or "base64".

prefix str

Optional fixed prefix (e.g. "sha256=").

Initialize the verifier.

Parameters:

Name Type Description Default
secret str | bytes

The shared secret. Strings are encoded as UTF-8.

required
algorithm str

hashlib algorithm name ("sha256", "sha512", ...).

'sha256'
header_name str

Header carrying the signature.

'X-Signature'
encoding str

"hex" (default) or "base64".

'hex'
prefix str

Optional fixed prefix on the header value (e.g. "sha256="). Stripped before comparison.

''
Source code in tempest_fastapi_sdk/api/webhooks.py
def __init__(
    self,
    secret: str | bytes,
    *,
    algorithm: str = "sha256",
    header_name: str = "X-Signature",
    encoding: str = "hex",
    prefix: str = "",
) -> None:
    """Initialize the verifier.

    Args:
        secret (str | bytes): The shared secret. Strings are
            encoded as UTF-8.
        algorithm (str): hashlib algorithm name (``"sha256"``,
            ``"sha512"``, ...).
        header_name (str): Header carrying the signature.
        encoding (str): ``"hex"`` (default) or ``"base64"``.
        prefix (str): Optional fixed prefix on the header value
            (e.g. ``"sha256="``). Stripped before comparison.
    """
    if encoding not in {"hex", "base64"}:
        raise ValueError("encoding must be 'hex' or 'base64'")
    if algorithm not in hashlib.algorithms_guaranteed:
        raise ValueError(f"unsupported hashlib algorithm: {algorithm}")
    self.secret: bytes = secret.encode() if isinstance(secret, str) else secret
    self.algorithm: str = algorithm
    self.header_name: str = header_name
    self.encoding: str = encoding
    self.prefix: str = prefix

ActivationResponseSchema

Bases: BaseSchema

Response body for POST /auth/activate/{token}.

Returned after the SDK has consumed a one-shot activation token and flipped the user's is_active=True. The user is automatically logged in — both JWTs are issued so the front-end can complete the post-confirmation redirect in one round-trip.

Attributes:

Name Type Description
user_id UUID

UUID of the freshly-activated user.

access_token str

Short-lived JWT.

refresh_token str

Long-lived JWT.

ActivationToken

Bases: BaseSchema

Service-level result of issuing an account-activation token.

Returned by :meth:UserAuthService.signup when activation is required — i.e. when AUTH_AUTO_ACTIVATE is false. The plaintext token is included here exactly once; only its SHA-256 hash is persisted, so this value cannot be recovered later. Use it to mail the activation link, log it during tests, or hand it back to the client in dev mode.

Attributes:

Name Type Description
user_id UUID

UUID of the user the token authorizes.

token str

Plaintext token — show once, never store.

url str

Front-end activation URL with the token already substituted into AUTH_ACTIVATION_URL_TEMPLATE.

expires_at datetime

UTC timestamp the token becomes invalid (default 7 days after issuance).

LoginResponseSchema

Bases: BaseSchema

Response body for POST /auth/login and the password-reset confirm.

Issued only when credentials validate; the bundled router reuses this shape for both POST /auth/login and POST /auth/password-reset/confirm since both flows end with an authenticated session.

Attributes:

Name Type Description
user_id UUID

UUID of the authenticated user.

access_token str

Short-lived JWT.

refresh_token str

Long-lived JWT.

LoginSchema

Bases: BaseSchema

Request body for POST /auth/login.

Standard email + password authentication. Both error paths (wrong password / unknown email / inactive user) collapse into the same generic UnauthorizedException so attackers can't enumerate accounts by reading the response.

Attributes:

Name Type Description
email EmailStr

Login identifier.

password str

Plaintext password — verified against the bcrypt hash stored on the row.

PasswordResetConfirmSchema

Bases: BaseSchema

Request body for POST /auth/password-reset/confirm.

Carries the opaque token the user copied from the reset link plus the replacement password. The service consumes the token (one-shot — used_at is stamped) and replaces the bcrypt hash atomically.

Attributes:

Name Type Description
token str

Opaque token issued by request. The plaintext form — the SDK stores only the hash, so this value cannot be guessed from the database.

new_password str

Plaintext replacement password. Length floor is enforced both schema-side and inside the service.

PasswordResetRequestSchema

Bases: BaseSchema

Request body for POST /auth/password-reset/request.

The endpoint always returns 202 with a generic message — even when the email isn't on file — so probing the endpoint can't enumerate accounts. The reset link travels via email (production) or in the response body when AUTH_RETURN_TOKEN_IN_RESPONSE=True (dev).

Attributes:

Name Type Description
email EmailStr

Email of the account asking for a reset.

PasswordResetResponseSchema

Bases: BaseSchema

Response body for POST /auth/password-reset/request.

message is the same generic string regardless of whether the email matched an account. reset_url is populated only when AUTH_RETURN_TOKEN_IN_RESPONSE=True or when the [email] extra isn't installed — otherwise the link only travels through SMTP.

Attributes:

Name Type Description
message str

Human-readable summary of the next step. Always identical across the "email found" / "email not found" branches.

reset_url str | None

Front-end reset URL when the caller asked for an inline response, None in production.

PasswordResetToken

Bases: BaseSchema

Service-level result of issuing a password-reset token.

Returned by :meth:UserAuthService.request_password_reset when the email matches a user and the caller asked the service to surface the link (either via AUTH_RETURN_TOKEN_IN_RESPONSE=True or because no :class:EmailUtils was wired). The plaintext token is one-shot, hashed at rest, and expires after AUTH_PASSWORD_RESET_TTL_SECONDS (default 1 hour).

Attributes:

Name Type Description
user_id UUID

UUID of the user whose password the token authorizes resetting.

token str

Plaintext token — display once, never store.

url str

Front-end reset URL with the token already substituted into AUTH_PASSWORD_RESET_URL_TEMPLATE.

expires_at datetime

UTC timestamp the token becomes invalid.

SignupResponseSchema

Bases: BaseSchema

Response body for POST /auth/signup.

The shape depends on the active settings:

  • When AUTH_AUTO_ACTIVATE=True the user is born active, activation_required=False and both access_token / refresh_token are populated — the client can log in immediately.
  • When AUTH_AUTO_ACTIVATE=False (production default) the user must confirm the activation link before logging in. activation_required=True, the tokens stay None and activation_url is set only when AUTH_RETURN_TOKEN_IN_RESPONSE=True (dev) or when the [email] extra isn't wired (so the link has to ship via the response instead of via SMTP).

Attributes:

Name Type Description
user_id UUID

Primary key of the freshly-inserted row.

activation_required bool

Whether the user still needs to confirm via the activation link.

activation_url str | None

Front-end URL the user must visit. None when the link travelled via email or activation was skipped.

access_token str | None

Short-lived JWT. Only set when activation_required=False.

refresh_token str | None

Long-lived JWT. Only set when activation_required=False.

SignupSchema

Bases: BaseSchema

Request body for POST /auth/signup.

Carries the credentials and the optional display name a new account starts with. The email is normalized to lowercase before insert (matches the unique-index convention every SDK user table follows); the password is hashed with bcrypt by :class:tempest_fastapi_sdk.PasswordUtils and never stored in plaintext.

Attributes:

Name Type Description
email EmailStr

Login identifier — validated by email-validator so malformed addresses fail at the Pydantic layer (422) instead of at insert time.

password str

Plaintext password. Length floor is enforced both here (schema-level) and inside :class:UserAuthService (service-level redundancy on purpose — Pydantic validators don't fire on direct service.signup(...) calls from other code paths).

name str | None

Optional display name shown in the admin UI / front-end profile. None keeps the column NULL.

UserAuthService

UserAuthService(
    *,
    user_model: type[BaseUserModel],
    token_model: type[BaseUserTokenModel],
    auth_settings: AuthSettings,
    jwt_settings: JWTSettings,
    email: EmailUtils | None = None,
    passwords: PasswordUtils | None = None,
    jwt: JWTUtils | None = None,
    db: AsyncDatabaseManager | None = None,
)

Compose UserModel + UserTokenModel into a full auth flow.

Example:

>>> service = UserAuthService(
...     db=db,
...     user_model=UserModel,
...     token_model=UserTokenModel,
...     auth_settings=settings,
...     jwt_settings=settings,
...     email=email_utils,
... )
>>> async with db.get_session_context() as s:
...     result = await service.signup(s, payload)

Every method takes the active AsyncSession explicitly so callers control the transaction boundary — the service never opens its own session.

Initialize the service.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

Concrete user model — usually src.db.models.UserModel.

required
token_model type[BaseUserTokenModel]

Concrete token model — usually src.db.models.UserTokenModel.

required
auth_settings AuthSettings

The mixin populating activation / reset behavior.

required
jwt_settings JWTSettings

The mixin populating signing keys and TTLs.

required
email EmailUtils | None

Configured email helper. When None, the service always returns the link in the response (and never tries to send).

None
passwords PasswordUtils | None

Override for tests; defaults to a fresh instance.

None
jwt JWTUtils | None

Override for tests; defaults to one built from jwt_settings.

None
db AsyncDatabaseManager | None

Optional handle for services that open their own sessions inside helpers like background tasks.

None
Source code in tempest_fastapi_sdk/auth/service.py
def __init__(
    self,
    *,
    user_model: type[BaseUserModel],
    token_model: type[BaseUserTokenModel],
    auth_settings: AuthSettings,
    jwt_settings: JWTSettings,
    email: EmailUtils | None = None,
    passwords: PasswordUtils | None = None,
    jwt: JWTUtils | None = None,
    db: AsyncDatabaseManager | None = None,
) -> None:
    """Initialize the service.

    Args:
        user_model (type[BaseUserModel]): Concrete user model
            — usually ``src.db.models.UserModel``.
        token_model (type[BaseUserTokenModel]): Concrete token
            model — usually ``src.db.models.UserTokenModel``.
        auth_settings (AuthSettings): The mixin populating
            activation / reset behavior.
        jwt_settings (JWTSettings): The mixin populating
            signing keys and TTLs.
        email (EmailUtils | None): Configured email helper.
            When ``None``, the service always returns the link
            in the response (and never tries to send).
        passwords (PasswordUtils | None): Override for tests;
            defaults to a fresh instance.
        jwt (JWTUtils | None): Override for tests; defaults
            to one built from ``jwt_settings``.
        db (AsyncDatabaseManager | None): Optional handle for
            services that open their own sessions inside
            helpers like background tasks.
    """
    self.user_model: type[BaseUserModel] = user_model
    self.token_model: type[BaseUserTokenModel] = token_model
    self.auth_settings: AuthSettings = auth_settings
    self.jwt_settings: JWTSettings = jwt_settings
    self.email: EmailUtils | None = email
    self.passwords: PasswordUtils = passwords or PasswordUtils()
    self.jwt: JWTUtils = jwt or JWTUtils(
        secret=jwt_settings.JWT_SECRET,
        algorithm=jwt_settings.JWT_ALGORITHM,
    )
    self.db: AsyncDatabaseManager | None = db

BaseController

BaseController(service: ServiceT)

Bases: Generic[ServiceT, ResponseT]

Thin orchestration layer between routers and services.

Following the SDK layering rules (router → controller → service → repository), controllers are kept present even when no orchestration is required so the import graph stays uniform. Override methods here when a single endpoint needs to call multiple services or apply cross-cutting policy; leave the pass-throughs untouched otherwise.

Generic parameters

ServiceT: The concrete service class. ResponseT: The response schema returned to the router.

Attributes:

Name Type Description
service ServiceT

The service the controller delegates to.

Initialize the controller.

Parameters:

Name Type Description Default
service ServiceT

The service to delegate to.

required
Source code in tempest_fastapi_sdk/controllers/base.py
def __init__(self, service: ServiceT) -> None:
    """Initialize the controller.

    Args:
        service (ServiceT): The service to delegate to.
    """
    self.service: ServiceT = service

BaseIntEnum

Bases: _EnumHelpers, int, Enum

Base class for integer-valued enums.

Mixing in int makes every member a genuine integer instance, so members compare equal to their values (Member == 1), serialize cleanly outside Pydantic, and bind directly to Integer database columns as their value.

BaseStrEnum

Bases: _EnumHelpers, str, Enum

Base class for string-valued enums.

Note

Deliberately a str + Enum mixin rather than :class:enum.StrEnum. The two differ in str(member) ("Cls.MEMBER" here vs. the bare value under StrEnum); keeping the mixin form preserves the behaviour consumers already rely on across services.

Mixing in str makes every member a genuine string instance, so members compare equal to their values (Member == "VALUE"), serialize cleanly outside Pydantic, and bind directly to String database columns as their value.

JSONFormatter

Bases: Formatter

Render every log record as a single-line JSON object.

Standard LogRecord fields are mapped to timestamp, level, logger and message. The current request ID (when present) is attached as request_id. Any additional keyword passed to the logger via extra={...} becomes a top-level key in the JSON payload.

AlembicHelper

AlembicHelper(config_path: str = 'alembic.ini', *, db_url: str | None = None)

High-level wrapper around the Alembic command surface.

Encapsulates a single alembic.ini configuration and exposes the operations that matter for day-to-day work — upgrade, downgrade, revision authoring, schema-vs-models check — without leaking Alembic internals into application code.

All methods are synchronous because Alembic itself is sync; run them from CLI scripts or from FastAPI's startup hook via asyncio.to_thread if you must call them from async code.

Attributes:

Name Type Description
config_path str

Path to the alembic.ini configuration.

Initialize the helper.

Parameters:

Name Type Description Default
config_path str

Path to alembic.ini. Resolved relative to the current working directory.

'alembic.ini'
db_url str | None

If provided, overrides sqlalchemy.url from the .ini file. Useful when the URL must come from settings/environment rather than the ini.

None
Source code in tempest_fastapi_sdk/db/migrations.py
def __init__(
    self,
    config_path: str = "alembic.ini",
    *,
    db_url: str | None = None,
) -> None:
    """Initialize the helper.

    Args:
        config_path (str): Path to ``alembic.ini``. Resolved
            relative to the current working directory.
        db_url (str | None): If provided, overrides
            ``sqlalchemy.url`` from the ``.ini`` file. Useful
            when the URL must come from settings/environment
            rather than the ini.
    """
    self.config_path: str = config_path
    self._db_url_override: str | None = db_url

AsyncDatabaseManager

AsyncDatabaseManager(
    db_url: str,
    *,
    echo: bool = False,
    pool_size: int = 10,
    max_overflow: int = 20,
    pool_recycle: int = 3600,
    connect_args: dict[str, Any] | None = None,
    poolclass: type[Pool] | None = None,
    **engine_kwargs: Any,
)

Manage the async SQLAlchemy engine and session lifecycle.

Handles engine creation tailored to the database backend (SQLite gets check_same_thread=False by default, everything else gets a pooled config), session factory construction, and table create/drop helpers. Designed to be instantiated once per application and reused across requests.

Backend detection uses sqlalchemy.engine.make_url so URLs like sqlite+aiosqlite://... are matched precisely without relying on substring tricks.

Attributes:

Name Type Description
is_sqlite bool

Whether the URL targets a SQLite backend.

The connection URL itself is stored on a private attribute so it never leaks through repr() or accidental logging. Use the :attr:db_url_safe property when a redacted form is needed.

Initialize the manager (does not open connections yet).

Parameters:

Name Type Description Default
db_url str

The database connection URL.

required
echo bool

Whether to emit SQL to stdout.

False
pool_size int

Number of permanent connections in the pool. Ignored for SQLite URLs.

10
max_overflow int

Extra connections allowed above the pool size. Ignored for SQLite URLs.

20
pool_recycle int

Recycle connections older than this many seconds. Ignored for SQLite URLs.

3600
connect_args dict[str, Any] | None

Driver-level arguments forwarded to create_async_engine (e.g. {"ssl": "require"} for asyncpg). SQLite always receives check_same_thread=False unless explicitly overridden here.

None
poolclass type[Pool] | None

Override SQLAlchemy's default pool class. Useful for tests (poolclass=NullPool) or specialized topologies.

None
**engine_kwargs Any

Any additional keyword arguments are passed through to create_async_engine verbatim.

{}
Source code in tempest_fastapi_sdk/db/connection.py
def __init__(
    self,
    db_url: str,
    *,
    echo: bool = False,
    pool_size: int = 10,
    max_overflow: int = 20,
    pool_recycle: int = 3600,
    connect_args: dict[str, Any] | None = None,
    poolclass: type[Pool] | None = None,
    **engine_kwargs: Any,
) -> None:
    """Initialize the manager (does not open connections yet).

    Args:
        db_url (str): The database connection URL.
        echo (bool): Whether to emit SQL to stdout.
        pool_size (int): Number of permanent connections in the
            pool. Ignored for SQLite URLs.
        max_overflow (int): Extra connections allowed above the
            pool size. Ignored for SQLite URLs.
        pool_recycle (int): Recycle connections older than this
            many seconds. Ignored for SQLite URLs.
        connect_args (dict[str, Any] | None): Driver-level
            arguments forwarded to ``create_async_engine``
            (e.g. ``{"ssl": "require"}`` for asyncpg). SQLite
            always receives ``check_same_thread=False`` unless
            explicitly overridden here.
        poolclass (type[Pool] | None): Override SQLAlchemy's
            default pool class. Useful for tests
            (``poolclass=NullPool``) or specialized topologies.
        **engine_kwargs: Any additional keyword arguments are
            passed through to ``create_async_engine`` verbatim.
    """
    self._db_url: str = db_url
    self.is_sqlite: bool = make_url(db_url).get_backend_name() == "sqlite"
    self._echo: bool = echo
    self._pool_size: int = pool_size
    self._max_overflow: int = max_overflow
    self._pool_recycle: int = pool_recycle
    self._connect_args: dict[str, Any] = dict(connect_args or {})
    self._poolclass: type[Pool] | None = poolclass
    self._engine_kwargs: dict[str, Any] = engine_kwargs
    self._engine: AsyncEngine | None = None
    self._session_maker: async_sessionmaker[AsyncSession] | None = None

AuditMixin

Add created_by / updated_by foreign-key columns.

Tracks which user (by UUID) last touched a row. The mixin only declares the columns — populating them is the application's responsibility, typically inside the service layer (where the current user is in scope) right before calling the repository.

Attributes:

Name Type Description
created_by UUID | None

UUID of the user that created the row. Nullable for system-generated rows.

updated_by UUID | None

UUID of the user that last updated the row. Nullable until the first update.

BaseModel

Bases: AsyncAttrs, DeclarativeBase

Abstract base for every SQLAlchemy model in the application.

Every concrete model inherits the four columns required by the SDK conventions: id (UUID primary key, cross-DB portable), is_active (soft-delete flag), created_at and updated_at (timezone-aware timestamps managed by the database).

The class is marked __abstract__ so SQLAlchemy will not try to map it directly. Concrete subclasses get an auto-generated __tablename__ from their class name (e.g. UserModeluser, OrderItemModelorder_item); the Model suffix is stripped and the remainder is snake-cased. Explicit __tablename__ declarations still win when set.

Equality and hashing use (type, id) so the same row loaded across different sessions compares equal — useful in tests and sets. Unflushed instances (id is None) fall back to Python identity.

Attributes:

Name Type Description
metadata MetaData

Configured with :data:NAMING_CONVENTION so Alembic generates deterministic constraint names.

id UUID

Primary key. Generated as UUID v4. Uses sqlalchemy.Uuid so the same Python type works against PostgreSQL, MySQL, SQLite and MSSQL.

is_active bool

Whether the record is active. Defaults to True.

created_at datetime

Creation timestamp populated by the database on insert.

updated_at datetime

Last-update timestamp refreshed by the database on every update.

BaseRepository

BaseRepository(
    session: AsyncSession,
    *,
    model: type[ModelType],
    not_found_exception: type[AppException] = NotFoundException,
    not_found_message: str | None = None,
    create_conflict_message: str | None = None,
    update_conflict_message: str | None = None,
    bulk_create_conflict_message: str | None = None,
    bulk_update_conflict_message: str | None = None,
)

Bases: Generic[ModelType]

Base async repository with generic CRUD operations.

Instantiate directly for plain CRUD (BaseRepository(session, model=UserModel)) or subclass when adding custom queries — the subclass forwards model / not_found_exception to super().__init__ instead of declaring class attributes. The constructor signature is the contract; there are no magic class attributes to override.

The default filter logic supports equality on every column plus the following conventions:

  • name (string) → case-insensitive ILIKE %value% search.
  • bool values → .is_(value) (correct SQL boolean check).
  • list values → .in_(values) membership.
  • date values → func.date(column) == value whole-day match.
  • start_in / end_in (date) → range filter against the model's date column when present, falling back to created_at.

All error messages can be customized per repository instance via the constructor kwargs (not_found_message, create_conflict_message, etc.); when omitted, sensible defaults derived from self.model.__name__ are used.

The same three abstract mappers map_to_schema / map_to_model / map_to_response are kept so concrete repositories own the translation between ORM rows and DTOs.

Attributes:

Name Type Description
model type[ModelType]

The SQLAlchemy model class operated on.

not_found_exception type[AppException]

Exception class raised when single-record lookups miss.

session AsyncSession

The async database session.

Initialize the repository.

Every *_message kwarg is optional — when not provided, the repository falls back to a generic message derived from the model class name (e.g. "User not found", "Conflict creating User").

Parameters:

Name Type Description Default
session AsyncSession

The async database session.

required
model type[ModelType]

The SQLAlchemy model class this repository operates on. Required.

required
not_found_exception type[AppException]

Exception class raised when single-record lookups miss. Defaults to :class:NotFoundException; pass a domain-specific subclass for richer 404 messages.

NotFoundException
not_found_message str | None

Message used when get, get_by_id, delete, soft_delete or restore find no matching record.

None
create_conflict_message str | None

Message used when add raises IntegrityError.

None
update_conflict_message str | None

Message used when update raises IntegrityError.

None
bulk_create_conflict_message str | None

Message used when add_all raises IntegrityError.

None
bulk_update_conflict_message str | None

Message used when update_many or bulk_update raises IntegrityError.

None

Raises:

Type Description
TypeError

When model is not a subclass of :class:BaseModel.

Source code in tempest_fastapi_sdk/db/repository.py
def __init__(
    self,
    session: AsyncSession,
    *,
    model: type[ModelType],
    not_found_exception: type[AppException] = NotFoundException,
    not_found_message: str | None = None,
    create_conflict_message: str | None = None,
    update_conflict_message: str | None = None,
    bulk_create_conflict_message: str | None = None,
    bulk_update_conflict_message: str | None = None,
) -> None:
    """Initialize the repository.

    Every ``*_message`` kwarg is optional — when not provided, the
    repository falls back to a generic message derived from the
    model class name (e.g. ``"User not found"``,
    ``"Conflict creating User"``).

    Args:
        session (AsyncSession): The async database session.
        model (type[ModelType]): The SQLAlchemy model class this
            repository operates on. Required.
        not_found_exception (type[AppException]): Exception class
            raised when single-record lookups miss. Defaults to
            :class:`NotFoundException`; pass a domain-specific
            subclass for richer 404 messages.
        not_found_message (str | None): Message used when ``get``,
            ``get_by_id``, ``delete``, ``soft_delete`` or
            ``restore`` find no matching record.
        create_conflict_message (str | None): Message used when
            ``add`` raises ``IntegrityError``.
        update_conflict_message (str | None): Message used when
            ``update`` raises ``IntegrityError``.
        bulk_create_conflict_message (str | None): Message used
            when ``add_all`` raises ``IntegrityError``.
        bulk_update_conflict_message (str | None): Message used
            when ``update_many`` or ``bulk_update`` raises
            ``IntegrityError``.

    Raises:
        TypeError: When ``model`` is not a subclass of
            :class:`BaseModel`.
    """
    if not isinstance(model, type) or not issubclass(model, BaseModel):
        raise TypeError(
            "BaseRepository `model` must be a subclass of BaseModel",
        )
    self.session: AsyncSession = session
    self.model: type[ModelType] = model
    self.not_found_exception: type[AppException] = not_found_exception
    name = self.model.__name__
    self._not_found_message: str = not_found_message or f"{name} not found"
    self._create_conflict_message: str = (
        create_conflict_message or f"Conflict creating {name}"
    )
    self._update_conflict_message: str = (
        update_conflict_message or f"Conflict updating {name}"
    )
    self._bulk_create_conflict_message: str = (
        bulk_create_conflict_message or f"Conflict creating {name} batch"
    )
    self._bulk_update_conflict_message: str = (
        bulk_update_conflict_message or f"Conflict updating {name} batch"
    )

BaseUserModel

Bases: BaseModel

Abstract user table with the columns the admin auth flow needs.

Inherits id/is_active/created_at/updated_at from :class:BaseModel and adds:

  • email (unique, indexed) — login identifier. Always stored lowercased; helpers handle the normalization.
  • hashed_password — bcrypt hash produced by :class:tempest_fastapi_sdk.PasswordUtils. Use :meth:set_password to write and :meth:check_password to verify so the hashing strategy stays consistent across callers.
  • is_admin — gate enforced by the admin auth backend; only users with is_admin=True may log in to /admin.
  • last_login_at — populated by the admin login view on every successful authentication.

The class is marked __abstract__ so SQLAlchemy does not try to map it directly; concrete projects subclass it and either keep the auto-derived __tablename__ (user) or override it.

Attributes:

Name Type Description
email str

Login identifier. Unique. 320 chars max (RFC 5321 mailbox limit).

hashed_password str

Bcrypt hash; never store plaintext.

is_admin bool

Whether the user can access the admin site.

last_login_at datetime | None

Last successful login timestamp.

BaseUserTokenModel

Bases: BaseModel

Abstract one-shot token used by the bundled auth flows.

Concrete subclasses pick the __tablename__ (user_tokens by convention) and add an FK to the project's concrete UserModel. The SDK never stores the plaintext token — only its hash via :func:tempest_fastapi_sdk.hash_opaque_token. The plaintext is returned exactly once (at creation) so it can be embedded in the activation / reset link.

Attributes:

Name Type Description
user_id UUID

Foreign key to the user this token authorizes. Inherits the project's user table name — concrete subclasses set the FK target explicitly.

token_hash str

SHA-256 hash of the plaintext token. Indexed + unique so lookups by hash are fast and duplicates impossible.

purpose str

One of :class:UserTokenPurpose.

expires_at datetime

UTC timestamp the token becomes invalid. redeem() checks this against now() on every consumption.

used_at datetime | None

UTC timestamp the token was redeemed. Non-null means the token is spent and must not be accepted again.

SoftDeleteMixin

Add a deleted_at timestamp for non-destructive deletes.

Pairs with the canonical is_active flag on :class:tempest_fastapi_sdk.BaseModel: is_active toggles visibility quickly while deleted_at records when the soft delete happened (useful for audit and retention policies).

A row is considered "alive" when deleted_at IS NULL. Filtering is the caller's responsibility — the mixin keeps the column declarative-only so it composes with arbitrary query strategies (global filters, partial indexes, repository hooks).

Attributes:

Name Type Description
deleted_at datetime | None

Timestamp of the soft delete, or None while the row is alive.

UserTokenPurpose

Bases: StrEnum

What a token authorizes when redeemed.

Each value maps to a distinct flow exposed by :class:tempest_fastapi_sdk.auth.UserAuthService:

  • ACTIVATION — confirm the email address the user signed up with.
  • PASSWORD_RESET — let the user pick a new password without the old one.
  • EMAIL_VERIFICATION — re-verify after the email was changed.

AppException

AppException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: HTTPException

Base exception for all application-level errors.

Concrete projects raise either a domain-specific subclass (kept around for except DomainError matching) or the base directly, passing code / status_code / message via constructor keyword arguments. Class-level attributes are the defaults each constructor argument falls back to, never required overrides::

class UserNotFoundError(NotFoundException):
    """Subclass exists only for isinstance/except matching."""

raise UserNotFoundError(
    "Usuário não encontrado",
    code="USER_NOT_FOUND",
    details={"email": email},
)

The matching exception handler (see :mod:tempest_fastapi_sdk.api.handlers) emits the JSON shape::

{
    "detail": "<message>",
    "code": "<code>",
    "details": {"<any>": "<context>"}
}

Class attributes (defaults the constructor falls back to): status_code (int): HTTP status code. message (str): Default human-readable message. code (str): Stable, machine-readable identifier.

Instance attributes

status_code (int): The status code attached to this instance. code (str): The error code attached to this instance. details (dict[str, Any]): Free-form context attached to the response payload.

Initialize the exception.

Parameters:

Name Type Description Default
message str | None

Override the class-level message.

None
code str | None

Override the class-level error code on this instance only — leaves other instances of the same class untouched.

None
status_code int | None

Override the class-level HTTP status code on this instance only.

None
details dict[str, Any] | None

Structured context to attach to the JSON response.

None
headers dict[str, str] | None

Optional HTTP headers to include in the response.

None
Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ConflictException

ConflictException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when a write would violate a uniqueness/integrity rule.

Typically surfaced by the repository when SQLAlchemy raises an IntegrityError on insert/update.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ExpiredTokenException

ExpiredTokenException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: UnauthorizedException

Raised when a JWT's exp claim is in the past.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

FileTooLargeException

FileTooLargeException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when an uploaded file exceeds the configured size limit.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ForbiddenException

ForbiddenException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when the caller is authenticated but lacks permission.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

InvalidFileTypeException

InvalidFileTypeException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when an uploaded file's extension or MIME is not allowed.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

InvalidTokenException

InvalidTokenException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: UnauthorizedException

Raised when a JWT fails signature or claim validation.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

NotFoundException

NotFoundException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when a single resource cannot be located.

Use for get_by_id / get_by_email style lookups. NEVER use for collection endpoints — those should return [] instead.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

TooManyRequestsException

TooManyRequestsException(
    message: str | None = None,
    *,
    retry_after_seconds: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when a client exceeds a rate limit or attempt budget.

Carries an optional Retry-After header (seconds) and mirrors the same value under details["retry_after_seconds"] so clients can back off without parsing headers. Used by :class:tempest_fastapi_sdk.utils.AttemptThrottle and suitable for any throttled flow (login, OTP, code verification).

Initialize the exception.

Parameters:

Name Type Description Default
message str | None

Override the class-level message.

None
retry_after_seconds int | None

Cooldown in seconds. When given, sets the Retry-After header and adds retry_after_seconds to details (unless already provided by the caller).

None
details dict[str, Any] | None

Structured context.

None
headers dict[str, str] | None

Extra response headers; merged with the Retry-After header when applicable.

None
Source code in tempest_fastapi_sdk/exceptions/too_many_requests.py
def __init__(
    self,
    message: str | None = None,
    *,
    retry_after_seconds: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        retry_after_seconds (int | None): Cooldown in seconds. When
            given, sets the ``Retry-After`` header and adds
            ``retry_after_seconds`` to ``details`` (unless already
            provided by the caller).
        details (dict[str, Any] | None): Structured context.
        headers (dict[str, str] | None): Extra response headers;
            merged with the ``Retry-After`` header when applicable.
    """
    merged_details: dict[str, Any] = dict(details or {})
    merged_headers: dict[str, str] = dict(headers or {})
    if retry_after_seconds is not None:
        merged_details.setdefault(
            "retry_after_seconds",
            retry_after_seconds,
        )
        merged_headers.setdefault(
            "Retry-After",
            str(retry_after_seconds),
        )
    super().__init__(
        message=message,
        details=merged_details,
        headers=merged_headers or None,
    )

UnauthorizedException

UnauthorizedException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when the caller is not authenticated.

Use for missing/invalid/expired credentials. For "authenticated but not allowed" cases, use :class:tempest_fastapi_sdk.exceptions.forbidden.ForbiddenException.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ValidationException

ValidationException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when input fails a business rule beyond Pydantic.

Pydantic emits 422 automatically for schema validation; use this for downstream rules that only the service layer can enforce.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

BasePaginationFilterSchema

Bases: BaseSchema

Base filter schema for paginated list endpoints.

Subclass it to add domain-specific filter fields. The base get_conditions method returns every populated field except the pagination/sort keys, which is the contract expected by :class:tempest_fastapi_sdk.db.repository.BaseRepository.paginate.

Field names and defaults mirror the BaseRepository.paginate keyword arguments so passing the schema straight through works without renaming:

.. code-block:: python

result = await repo.paginate(
    filters=f.get_conditions(),
    order_by=f.order_by,
    page=f.page,
    page_size=f.page_size,
    ascending=f.ascending,
)

Attributes:

Name Type Description
page int

The page number to retrieve (1-indexed).

page_size int

The number of items per page.

order_by str | None

The column name to order by. None falls back to the repository default (created_at descending).

ascending bool

Whether to order ascending. Ignored when order_by is None.

is_active bool | None

Filter by active status. None returns both active and inactive rows.

BasePaginationSchema

Bases: BaseSchema, Generic[T]

Generic envelope returned by paginated endpoints.

Wraps the page of items together with the pagination metadata the frontend needs to render controls. Field names match the request-side :class:BasePaginationFilterSchema and the repository keyword arguments, so the round-trip stays free of renames.

Attributes:

Name Type Description
items list[T]

The items in the current page.

total int

The total number of items across all pages.

page int

The current page number (1-indexed).

page_size int

The number of items per page.

pages int

The total number of pages.

BaseResponseSchema

Bases: BaseSchema

Response schema with the four columns every ORM record carries.

Used as the parent of any *ResponseSchema whose payload mirrors a row from a table inheriting from :class:tempest_fastapi_sdk.db.model.BaseModel. created_at and updated_at are normalized to UTC after validation so the API always emits timezone-aware timestamps regardless of how the DB driver returned them.

Attributes:

Name Type Description
id UUID

The unique identifier of the record.

is_active bool

Whether the record is active (soft-delete convention).

created_at datetime

The creation timestamp, normalized to UTC.

updated_at datetime

The last update timestamp, normalized to UTC.

BaseSchema

Bases: BaseModel

Base class for every Pydantic schema in an application.

Centralizes the configuration that all DTOs share: ignore extra fields, allow building schemas from ORM attributes, serialize enum values, strip whitespace from strings, and validate assignments after construction.

Attributes:

Name Type Description
model_config ConfigDict

The Pydantic configuration.

CursorPaginationFilterSchema

Bases: BaseSchema

Request filter for cursor-based pagination endpoints.

Cursor pagination scales better than offset pagination on large tables (no COUNT(*), stable under concurrent inserts) at the cost of losing random-access semantics. Subclass to add domain filters; :meth:get_conditions strips the cursor/sort keys automatically.

Attributes:

Name Type Description
cursor str | None

Opaque cursor returned by the previous page. None requests the first page.

limit int

Maximum number of items to return.

order_by str

Column to sort by. Must be a sortable column with a stable secondary tie-break (id is appended automatically by the repository).

ascending bool

Whether to sort ascending. Defaults to False so newest rows surface first.

CursorPaginationSchema

Bases: BaseSchema, Generic[T]

Generic envelope returned by cursor-paginated endpoints.

Attributes:

Name Type Description
items list[T]

The items in the current page.

next_cursor str | None

Cursor to request the next page, or None when no more results exist.

has_more bool

Whether another page is available.

limit int

The page size used to produce this payload.

LogEntrySchema

Bases: BaseSchema

A single structured log record parsed from a JSON log file.

The SDK's :class:tempest_fastapi_sdk.JSONFormatter writes one JSON object per line. This schema mirrors its core fields and accepts any additional extra={...} keys (e.g. path, request_id, http_500) via extra="allow" so nothing is silently dropped by the /logs endpoint.

Attributes:

Name Type Description
timestamp str

ISO-8601 UTC timestamp (...Z).

level str

Log level name ("INFO", "ERROR", ...).

logger str

Name of the logger that emitted the record.

message str

The formatted log message.

request_id str | None

Correlation ID when present.

exception str | None

Formatted traceback when the record carried exc_info.

BaseService

BaseService(repository: RepositoryT)

Bases: Generic[RepositoryT, ResponseT]

Thin business-logic layer wrapping a :class:BaseRepository.

The default implementation exposes CRUD pass-through methods that delegate to the repository and apply map_to_response so the surface matches what routers/controllers consume. Concrete services should override methods that involve orchestration (multi-repository writes, external side effects, domain rules); pure pass-through methods can be left untouched.

Generic parameters

RepositoryT: The concrete repository class. ResponseT: The response schema returned by the service.

Attributes:

Name Type Description
repository RepositoryT

The repository the service delegates to.

Initialize the service.

Parameters:

Name Type Description Default
repository RepositoryT

The repository to delegate to.

required
Source code in tempest_fastapi_sdk/services/base.py
def __init__(self, repository: RepositoryT) -> None:
    """Initialize the service.

    Args:
        repository (RepositoryT): The repository to delegate to.
    """
    self.repository: RepositoryT = repository

MemorySessionStore

MemorySessionStore()

In-process :class:SessionStore for dev, tests, single-replica.

Stores sessions in a dict keyed by their hashed id; a secondary index by user_id powers list_by_user / delete_by_user without scanning. Expired rows are pruned on access — no background task needed.

Initialize the in-memory store.

Source code in tempest_fastapi_sdk/sessions/store.py
def __init__(self) -> None:
    """Initialize the in-memory store."""
    self._sessions: dict[str, Session] = {}
    self._by_user: dict[UUID, set[str]] = {}
    self._lock: asyncio.Lock = asyncio.Lock()

RedisSessionStore

RedisSessionStore(client: Redis, *, prefix: str = 'tempest:')

:class:SessionStore backed by an async redis client.

Schema:

  • Each session is stored at {prefix}sess:{hash} as JSON with a TTL set to (expires_at - now) so Redis evicts the key on its own — no janitor process needed.
  • The user → session index lives at {prefix}user:{user_id} as a Redis SET of session hashes. Entries are removed on delete and the whole SET is dropped on delete_by_user.

Requires the [cache] extra so the redis async client is available.

Initialize the Redis-backed store.

Parameters:

Name Type Description Default
client Redis

Async Redis client (e.g. AsyncRedisManager.client).

required
prefix str

Key prefix so session keys do not collide with other cached data.

'tempest:'
Source code in tempest_fastapi_sdk/sessions/store.py
def __init__(
    self,
    client: Redis,
    *,
    prefix: str = "tempest:",
) -> None:
    """Initialize the Redis-backed store.

    Args:
        client (Redis): Async Redis client (e.g.
            ``AsyncRedisManager.client``).
        prefix (str): Key prefix so session keys do not collide
            with other cached data.
    """
    self.client: Redis = client
    self.prefix: str = prefix

Session

Bases: BaseSchema

A live server-side session.

Stored in the configured :class:SessionStore keyed by the SHA-256 hash of the session id (the plaintext lives only in the cookie). Mirrors what every session-backed auth flow needs: user identity, lifetime bounds, originating client metadata for revocation UX ("you're signed in on Chrome from São Paulo"), and a free-form data bag for app-level state.

Attributes:

Name Type Description
session_id str

SHA-256 hex digest of the cookie value — NOT the plaintext. The plaintext leaves over Set-Cookie exactly once.

user_id UUID

Owner of the session.

created_at datetime

UTC timestamp when the session was issued.

expires_at datetime

UTC timestamp after which the session is rejected. Refreshed by :meth:SessionAuth.touch when sliding TTLs are in effect.

last_seen_at datetime

UTC timestamp of the last request that resolved the session. Updated by the middleware on every hit.

ip str | None

Client IP recorded at session creation. Useful for the "list active sessions" UI.

user_agent str | None

User-Agent header recorded at session creation.

data dict[str, Any]

Arbitrary JSON-serializable bag — shopping cart id, last-seen route, locale preference, etc.

SessionAuth

SessionAuth(
    *,
    user_model: type[BaseUserModel],
    store: SessionStore,
    settings: SessionSettings,
    passwords: PasswordUtils | None = None,
)

Server-side session lifecycle orchestrator.

Mount one instance per FastAPI app. Stateless — the only state lives in the injected :class:SessionStore and the project's UserModel table.

Initialize the service.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

Concrete user model (typically src.db.models.UserModel) used to resolve email → password hash on authenticate.

required
store SessionStore

Persistence backend (:class:MemorySessionStore or :class:RedisSessionStore).

required
settings SessionSettings

TTL / cookie / rotation flags driving the lifecycle.

required
passwords PasswordUtils | None

Override for tests; defaults to a fresh PasswordUtils().

None
Source code in tempest_fastapi_sdk/sessions/service.py
def __init__(
    self,
    *,
    user_model: type[BaseUserModel],
    store: SessionStore,
    settings: SessionSettings,
    passwords: PasswordUtils | None = None,
) -> None:
    """Initialize the service.

    Args:
        user_model (type[BaseUserModel]): Concrete user model
            (typically ``src.db.models.UserModel``) used to
            resolve email → password hash on ``authenticate``.
        store (SessionStore): Persistence backend
            (:class:`MemorySessionStore` or
            :class:`RedisSessionStore`).
        settings (SessionSettings): TTL / cookie / rotation
            flags driving the lifecycle.
        passwords (PasswordUtils | None): Override for tests;
            defaults to a fresh ``PasswordUtils()``.
    """
    self.user_model: type[BaseUserModel] = user_model
    self.store: SessionStore = store
    self.settings: SessionSettings = settings
    self.passwords: PasswordUtils = passwords or PasswordUtils()

SessionLoginSchema

Bases: BaseSchema

Payload for POST /auth/session/login.

SessionMiddleware

SessionMiddleware(
    app: ASGIApp, *, session_auth: SessionAuth, settings: SessionSettings
)

Bases: BaseHTTPMiddleware

ASGI middleware that resolves the session cookie per request.

Attach with::

app.add_middleware(
    SessionMiddleware,
    session_auth=session_auth,
    settings=session_settings,
)

After the middleware runs, every handler in the chain can read request.state.session — a :class:Session instance when the cookie was valid, None otherwise. Handlers that require authentication should depend on :func:make_session_dependency instead of poking request.state directly so missing sessions raise a clean 401 envelope.

Initialize the middleware.

Parameters:

Name Type Description Default
app ASGIApp

Wrapped ASGI app — Starlette passes this automatically when used with add_middleware.

required
session_auth SessionAuth

Configured service used to resolve cookies into sessions.

required
settings SessionSettings

Read for the cookie name.

required
Source code in tempest_fastapi_sdk/sessions/middleware.py
def __init__(
    self,
    app: ASGIApp,
    *,
    session_auth: SessionAuth,
    settings: SessionSettings,
) -> None:
    """Initialize the middleware.

    Args:
        app (ASGIApp): Wrapped ASGI app — Starlette passes this
            automatically when used with ``add_middleware``.
        session_auth (SessionAuth): Configured service used to
            resolve cookies into sessions.
        settings (SessionSettings): Read for the cookie name.
    """
    super().__init__(app)
    self.session_auth: SessionAuth = session_auth
    self.settings: SessionSettings = settings

SessionResponseSchema

Bases: BaseSchema

Body returned by POST /auth/session/login.

The session id itself is delivered via Set-Cookie — deliberately NOT in this body — so JavaScript cannot read it (HttpOnly cookies). The body carries everything the frontend actually needs to render an authenticated state.

SessionStore

Bases: Protocol

Persistence protocol every session backend implements.

SessionSummarySchema

Bases: BaseSchema

Public-safe projection of a :class:Session used by list endpoints.

Drops session_id (so revealing the list does NOT leak any secret) and renames the visible identifier to id — a stable UUID derived from the hashed session id by truncation, suitable for DELETE /auth/session/{id} revocation calls.

AuthSettings

Bases: BaseSettings

Configuration for the bundled signup / activation / reset flows.

Consumed by :class:tempest_fastapi_sdk.auth.UserAuthService and :func:tempest_fastapi_sdk.make_auth_router. Each flag has a sensible production default; flip AUTH_AUTO_ACTIVATE or AUTH_RETURN_TOKEN_IN_RESPONSE only in dev / CI.

BaseAppSettings

Bases: BaseSettings

Shared configuration for Settings classes across projects.

Provides the canonical pydantic-settings config block; concrete projects subclass this and add their domain-specific fields (database URLs, secrets, third-party keys, etc.).

The defaults:

  • env_file=".env" — load environment variables from a local .env file when present.
  • extra="ignore" — silently drop unexpected env vars instead of raising at startup.
  • case_sensitive=True — env var names are matched exactly.
  • frozen=True — settings are immutable after construction.
  • str_strip_whitespace=True — trim accidental whitespace around env values.
  • from_attributes=True — allow building from objects with attribute access (rarely needed for settings, but harmless).

Attributes:

Name Type Description
model_config SettingsConfigDict

The pydantic-settings configuration.

CORSSettings

Bases: BaseSettings

CORS middleware configuration.

.. warning:: The default CORS_ORIGINS=["*"] is permissive on purpose so local development works out of the box. Never ship this default to production — set CORS_ORIGINS to the explicit list of trusted frontend origins. "*" is also incompatible with CORS_ALLOW_CREDENTIALS=True (browsers ignore credentialed requests sent to a wildcard origin).

DatabaseSettings

Bases: BaseSettings

SQLAlchemy database connection configuration.

EmailSettings

Bases: BaseSettings

SMTP / transactional email configuration.

Mirrors the constructor arguments of :class:tempest_fastapi_sdk.EmailUtils so a service can wire it up with EmailUtils(**settings.email_kwargs()).

JWTSettings

Bases: BaseSettings

JWT signing and verification configuration.

LogSettings

Bases: BaseSettings

Structured logging configuration.

MinIOSettings

Bases: BaseSettings

MinIO / S3-compatible object storage configuration.

Consumed by :class:tempest_fastapi_sdk.AsyncMinIOClient. The same shape works for any S3-compatible target (AWS S3, MinIO, Backblaze B2, Cloudflare R2, Wasabi, DigitalOcean Spaces).

RabbitMQSettings

Bases: BaseSettings

RabbitMQ / FastStream broker configuration.

RedisSettings

Bases: BaseSettings

Redis connection configuration.

ServerSettings

Bases: BaseSettings

HTTP server bind configuration.

SessionSettings

Bases: BaseSettings

Server-side session cookie + storage configuration.

Consumed by :class:tempest_fastapi_sdk.SessionAuth, :class:tempest_fastapi_sdk.SessionMiddleware, and :func:tempest_fastapi_sdk.make_session_router. Defaults assume HTTPS in production (SESSION_COOKIE_SECURE=True) and a same-site SaaS topology (SESSION_COOKIE_SAMESITE="lax") — relax both only for local HTTP development.

TaskIQSettings

Bases: BaseSettings

TaskIQ broker / result backend configuration.

Use this when the TaskIQ broker is not the same RabbitMQ / Redis instance covered by :class:RabbitMQSettings / :class:RedisSettings.

TokenSettings

Bases: BaseSettings

Shared-secret X-Token configuration.

Used by :func:tempest_fastapi_sdk.make_token_dependency for internal service-to-service authentication. Validation is performed with :func:hmac.compare_digest.

UploadSettings

Bases: BaseSettings

File upload constraints.

Mirrors the constructor arguments of :class:tempest_fastapi_sdk.UploadUtils.

WebPushSettings

Bases: BaseSettings

Web Push / VAPID configuration.

Mirrors the constructor arguments of :class:tempest_fastapi_sdk.WebPushDispatcher.

WebSocketSettings

Bases: BaseSettings

WebSocket router configuration.

Consumed by :func:tempest_fastapi_sdk.make_websocket_router and :class:tempest_fastapi_sdk.WebSocketHub. Defaults are tuned for typical browser ↔ FastAPI deployments — heartbeats every 30s, drop after 60s without pong, five concurrent connections per user.

EventStream

EventStream(*, heartbeat_seconds: float | None = 15.0)

Async in-memory queue feeding one SSE HTTP connection.

A handler builds one stream per client request, publish-es events from anywhere in the application (background tasks, websockets, dependency callbacks), and passes :meth:stream to :func:sse_response. A None enqueued by :meth:close terminates the iteration so the response completes cleanly.

Heartbeats are emitted as SSE comments (: keepalive lines) when the queue stays empty for longer than heartbeat_seconds; this keeps load-balancers from closing idle TCP connections.

Attributes:

Name Type Description
heartbeat_seconds float | None

Idle interval that triggers a comment heartbeat. None disables heartbeats.

Initialize the stream.

Parameters:

Name Type Description Default
heartbeat_seconds float | None

Idle interval before a comment heartbeat is emitted.

15.0
Source code in tempest_fastapi_sdk/sse/event_stream.py
def __init__(self, *, heartbeat_seconds: float | None = 15.0) -> None:
    """Initialize the stream.

    Args:
        heartbeat_seconds (float | None): Idle interval before
            a comment heartbeat is emitted.
    """
    self._queue: asyncio.Queue[ServerSentEvent | None] = asyncio.Queue()
    self.heartbeat_seconds: float | None = heartbeat_seconds

ServerSentEvent dataclass

ServerSentEvent(
    data: Any = "",
    event: str | None = None,
    id: str | None = None,
    retry: int | None = None,
    comment: str | None = None,
)

A single SSE frame.

Encodes to the line-based wire format defined by the spec (https://html.spec.whatwg.org/multipage/server-sent-events.html). data may be a string, bytes, or any JSON-serializable Python object — non-string payloads are JSON-encoded before transmission.

Attributes:

Name Type Description
data Any

The event payload.

event str | None

Optional event name; the browser routes EventSource.addEventListener(name, ...) by this.

id str | None

Optional Last-Event-ID value used by the browser to resume after a reconnect.

retry int | None

Reconnection delay (milliseconds) the browser should use after a connection drop.

comment str | None

Optional comment line prepended to the frame (renders as : comment); useful for heartbeats.

AsyncMinIOClient

AsyncMinIOClient(
    endpoint: str,
    access_key: str,
    secret_key: str,
    *,
    default_bucket: str = "uploads",
    secure: bool = False,
    region: str = "us-east-1",
    session_token: str | None = None,
)

Async-friendly facade over minio.Minio.

Use as an async context manager when you want explicit cleanup, or hold a long-lived instance on the FastAPI app — the underlying Minio client is thread-safe and reuses its connection pool.

Example:

>>> from tempest_fastapi_sdk import AsyncMinIOClient
>>> storage = AsyncMinIOClient(
...     endpoint="localhost:9000",
...     access_key="minioadmin",
...     secret_key="minioadmin",
...     default_bucket="uploads",
... )
>>> await storage.ensure_bucket()
>>> await storage.put_object("hello.txt", b"world")
>>> body = await storage.get_object_bytes("hello.txt")
>>> assert body == b"world"

Initialize the client.

Parameters:

Name Type Description Default
endpoint str

host[:port] without scheme.

required
access_key str

S3 access key.

required
secret_key str

S3 secret key.

required
default_bucket str

Bucket used by object operations when no explicit bucket keyword is passed. Created by :meth:ensure_bucket.

'uploads'
secure bool

Use HTTPS when True.

False
region str

S3 region. Match the bucket region for AWS S3; any value works for MinIO.

'us-east-1'
session_token str | None

Optional STS session token for temporary credentials.

None

Raises:

Type Description
ImportError

When the minio package is not installed. Install the [minio] extra: pip install tempest-fastapi-sdk[minio].

Source code in tempest_fastapi_sdk/storage/minio_client.py
def __init__(
    self,
    endpoint: str,
    access_key: str,
    secret_key: str,
    *,
    default_bucket: str = "uploads",
    secure: bool = False,
    region: str = "us-east-1",
    session_token: str | None = None,
) -> None:
    """Initialize the client.

    Args:
        endpoint (str): ``host[:port]`` without scheme.
        access_key (str): S3 access key.
        secret_key (str): S3 secret key.
        default_bucket (str): Bucket used by object operations
            when no explicit ``bucket`` keyword is passed.
            Created by :meth:`ensure_bucket`.
        secure (bool): Use HTTPS when ``True``.
        region (str): S3 region. Match the bucket region for
            AWS S3; any value works for MinIO.
        session_token (str | None): Optional STS session token
            for temporary credentials.

    Raises:
        ImportError: When the ``minio`` package is not
            installed. Install the ``[minio]`` extra:
            ``pip install tempest-fastapi-sdk[minio]``.
    """
    try:
        from minio import Minio
    except ImportError as exc:  # pragma: no cover - exercised via extras
        raise ImportError(
            "AsyncMinIOClient requires the 'minio' package. "
            "Install with: pip install tempest-fastapi-sdk[minio]"
        ) from exc

    self.endpoint: str = endpoint
    self.default_bucket: str = default_bucket
    self.region: str = region
    self.secure: bool = secure
    self.client: Minio = Minio(
        endpoint,
        access_key=access_key,
        secret_key=secret_key,
        secure=secure,
        region=region,
        session_token=session_token,
    )

ObjectStat dataclass

ObjectStat(
    bucket: str,
    key: str,
    size: int,
    etag: str | None,
    content_type: str | None,
    last_modified: datetime | None,
    metadata: dict[str, str],
    raw: Object,
)

Subset of object metadata returned by :meth:AsyncMinIOClient.stat_object.

The full minio.datatypes.Object instance is also reachable via the raw attribute when you need the long tail of fields (version id, owner, restoration state, etc.).

Attributes:

Name Type Description
bucket str

Bucket the object lives in.

key str

Object key (S3 path).

size int

Size in bytes.

etag str | None

Server-side ETag (quotes stripped).

content_type str | None

MIME type recorded at upload.

last_modified datetime | None

Last modification timestamp in UTC.

metadata dict[str, str]

User metadata keyed without the x-amz-meta- prefix.

raw Object

Underlying minio Object for advanced use (versioning id, owner, restore state, …).

AttemptThrottle

AttemptThrottle(
    backend: ThrottleBackend,
    *,
    max_attempts: int,
    window_seconds: int,
    namespace: str = "throttle",
    fail_open: bool = True,
)

Fixed-window failure counter over an injected async KV backend.

Initialize the throttle.

Parameters:

Name Type Description Default
backend ThrottleBackend

Async KV store (e.g. redis.asyncio.Redis).

required
max_attempts int

Failures allowed before a key is blocked. Must be >= 1.

required
window_seconds int

Sliding window length (also the TTL applied on the first failure). Must be > 0.

required
namespace str

Key prefix so multiple throttles can share a backend without colliding.

'throttle'
fail_open bool

When True (default), backend errors degrade to "allowed" instead of raising — a cache outage must not lock every user out.

True

Raises:

Type Description
ValueError

If max_attempts < 1 or window_seconds <= 0.

Source code in tempest_fastapi_sdk/utils/throttle.py
def __init__(
    self,
    backend: ThrottleBackend,
    *,
    max_attempts: int,
    window_seconds: int,
    namespace: str = "throttle",
    fail_open: bool = True,
) -> None:
    """Initialize the throttle.

    Args:
        backend (ThrottleBackend): Async KV store (e.g.
            ``redis.asyncio.Redis``).
        max_attempts (int): Failures allowed before a key is
            blocked. Must be ``>= 1``.
        window_seconds (int): Sliding window length (also the TTL
            applied on the first failure). Must be ``> 0``.
        namespace (str): Key prefix so multiple throttles can share
            a backend without colliding.
        fail_open (bool): When ``True`` (default), backend errors
            degrade to "allowed" instead of raising — a cache
            outage must not lock every user out.

    Raises:
        ValueError: If ``max_attempts < 1`` or ``window_seconds <= 0``.
    """
    if max_attempts < 1:
        raise ValueError("max_attempts must be >= 1")
    if window_seconds <= 0:
        raise ValueError("window_seconds must be > 0")
    self._backend: ThrottleBackend = backend
    self.max_attempts: int = max_attempts
    self.window_seconds: int = window_seconds
    self._namespace: str = namespace
    self._fail_open: bool = fail_open

CircuitOpenError

CircuitOpenError(host: str)

Bases: Exception

Raised when the circuit-breaker rejects a call.

Carries the host that tripped the breaker so callers can branch on it (e.g. fall back to a cache or a queue).

Initialize.

Parameters:

Name Type Description Default
host str

The host whose breaker is open.

required
Source code in tempest_fastapi_sdk/utils/http_client.py
def __init__(self, host: str) -> None:
    """Initialize.

    Args:
        host (str): The host whose breaker is open.
    """
    super().__init__(f"circuit open for host {host!r}")
    self.host: str = host

CPUMetrics dataclass

CPUMetrics(
    percent: float,
    cores_logical: int,
    cores_physical: int,
    load_average: tuple[float, float, float] | None = None,
)

CPU usage snapshot.

Attributes:

Name Type Description
percent float

Aggregate CPU utilization (0-100).

cores_logical int

Logical core count (including SMT).

cores_physical int

Physical core count.

load_average tuple[float, float, float] | None

⅕/15-minute load averages on POSIX; None on Windows.

DiskMetrics dataclass

DiskMetrics(
    path: str, total_bytes: int, used_bytes: int, free_bytes: int, percent: float
)

Disk usage snapshot for a single mount point.

Attributes:

Name Type Description
path str

The mount point inspected.

total_bytes int

Filesystem total capacity.

used_bytes int

Used bytes.

free_bytes int

Free bytes.

percent float

Used percentage (0-100).

DownloadUtils

DownloadUtils(base_dir: Path | str)

Serve files from a base directory as inline or attachment responses.

All disk reads are confined to base_dir: a relative path that resolves outside it (../ traversal, absolute paths, symlink escapes) raises :class:NotFoundException rather than leaking arbitrary files. The same 404 is raised when the target does not exist or is not a regular file, so callers never have to special-case the difference between "missing" and "forbidden".

Attributes:

Name Type Description
base_dir Path

Resolved root every served file must live under.

Initialize.

Parameters:

Name Type Description Default
base_dir Path | str

Root directory files are served from. Resolved to an absolute path on construction; the directory is not required to exist yet.

required
Source code in tempest_fastapi_sdk/utils/download.py
def __init__(self, base_dir: Path | str) -> None:
    """Initialize.

    Args:
        base_dir (Path | str): Root directory files are served from.
            Resolved to an absolute path on construction; the
            directory is not required to exist yet.
    """
    self.base_dir: Path = Path(base_dir).resolve()

EmailUtils

EmailUtils(
    host: str,
    port: int,
    *,
    from_addr: str,
    username: str | None = None,
    password: str | None = None,
    use_tls: bool = False,
    use_starttls: bool = True,
    timeout: float = 30.0,
    template_dir: str | Path | None = None,
)

Send transactional emails via SMTP.

Connection configuration is supplied at construction time; each :meth:send call opens a fresh SMTP connection (aiosmtplib's high-level send helper handles connect/login/quit). For high-volume scenarios consider holding a persistent connection via aiosmtplib.SMTP directly.

Attributes:

Name Type Description
host str

SMTP server hostname.

port int

SMTP port.

from_addr str

Default sender address used as the From header.

use_tls bool

Whether to connect using SSL/TLS from the start (port 465 style).

use_starttls bool

Whether to upgrade to TLS via STARTTLS after connect (port 587 style).

Initialize.

Parameters:

Name Type Description Default
host str

SMTP server hostname.

required
port int

SMTP port. Common values: 25 (plain), 465 (SSL/TLS), 587 (STARTTLS).

required
from_addr str

Default sender address.

required
username str | None

Auth username.

None
password str | None

Auth password.

None
use_tls bool

Connect using SSL/TLS immediately. Set this for port 465.

False
use_starttls bool

Upgrade to TLS via STARTTLS after connect. Set this for port 587 (default).

True
timeout float

SMTP socket timeout in seconds.

30.0
template_dir str | Path | None

Directory holding Jinja2 templates for :meth:render_template. Optional — templates can be opted into later, and the directory is only loaded on first render. Requires the [email] extra (Jinja2 ships alongside aiosmtplib).

None

Raises:

Type Description
ImportError

When the [email] extra is not installed.

Source code in tempest_fastapi_sdk/utils/email.py
def __init__(
    self,
    host: str,
    port: int,
    *,
    from_addr: str,
    username: str | None = None,
    password: str | None = None,
    use_tls: bool = False,
    use_starttls: bool = True,
    timeout: float = 30.0,
    template_dir: str | Path | None = None,
) -> None:
    """Initialize.

    Args:
        host (str): SMTP server hostname.
        port (int): SMTP port. Common values: ``25`` (plain),
            ``465`` (SSL/TLS), ``587`` (STARTTLS).
        from_addr (str): Default sender address.
        username (str | None): Auth username.
        password (str | None): Auth password.
        use_tls (bool): Connect using SSL/TLS immediately. Set
            this for port ``465``.
        use_starttls (bool): Upgrade to TLS via STARTTLS after
            connect. Set this for port ``587`` (default).
        timeout (float): SMTP socket timeout in seconds.
        template_dir (str | Path | None): Directory holding Jinja2
            templates for :meth:`render_template`. Optional —
            templates can be opted into later, and the directory is
            only loaded on first render. Requires the ``[email]``
            extra (Jinja2 ships alongside aiosmtplib).

    Raises:
        ImportError: When the ``[email]`` extra is not installed.
    """
    if _aiosmtplib is None:
        raise ImportError(
            "EmailUtils requires the [email] extra. "
            "Install with `pip install tempest-fastapi-sdk[email]`."
        )
    self.host: str = host
    self.port: int = port
    self.from_addr: str = from_addr
    self._username: str | None = username
    self._password: str | None = password
    self.use_tls: bool = use_tls
    self.use_starttls: bool = use_starttls
    self._timeout: float = timeout
    self._template_dir: Path | None = (
        Path(template_dir) if template_dir is not None else None
    )
    self._jinja_env: jinja2.Environment | None = None

GPUMetrics dataclass

GPUMetrics(
    index: int,
    name: str,
    memory_total_bytes: int,
    memory_used_bytes: int,
    memory_free_bytes: int,
    utilization_percent: float,
    temperature_celsius: float | None = None,
)

Single-GPU usage snapshot.

Populated by :func:pynvml (NVIDIA only). Non-NVIDIA hosts get an empty list from :meth:MetricsUtils.gpus.

Attributes:

Name Type Description
index int

Device index (0-based).

name str

Device model name.

memory_total_bytes int

VRAM capacity.

memory_used_bytes int

VRAM in use.

memory_free_bytes int

VRAM free.

utilization_percent float

GPU utilization (0-100).

temperature_celsius float | None

Core temperature, when reported by the driver.

HTTPClient

HTTPClient(
    *,
    base_url: str = "",
    timeout: float = 10.0,
    retry_policy: RetryPolicy | None = None,
    failure_threshold: int = 5,
    recovery_seconds: float = 30.0,
    default_headers: Mapping[str, str] | None = None,
    verify_tls: bool = True,
    propagate_request_id: bool = True,
)

Async HTTP client with retries, circuit-breaker and request-id propagation.

Example:

>>> client = HTTPClient(base_url="https://api.example.com")
>>> async with client:
...     response = await client.get("/users/me")
...     payload: dict[str, Any] = response.json()

The client is safe to share across requests on the same event loop — internally each call uses the shared :class:httpx.AsyncClient connection pool.

Initialize.

Parameters:

Name Type Description Default
base_url str

Prepended to relative paths. Use empty string to require absolute URLs at the call site.

''
timeout float

Per-request timeout in seconds. Overridable per call.

10.0
retry_policy RetryPolicy | None

Retry configuration. None uses the defaults (3 attempts, ~0.5/½s backoff).

None
failure_threshold int

Consecutive 5xx/network errors that trip the circuit per host. 0 disables the breaker.

5
recovery_seconds float

Seconds the breaker stays open before allowing one half-open probe.

30.0
default_headers Mapping[str, str] | None

Headers attached to every request (e.g. Authorization).

None
verify_tls bool

Whether to verify TLS certificates. Default True — flip only for internal mTLS or dev with self-signed certs.

True
propagate_request_id bool

When True (default), attach X-Request-ID from the current contextvar to outbound requests.

True

Raises:

Type Description
ImportError

When the [http] extra is missing.

Source code in tempest_fastapi_sdk/utils/http_client.py
def __init__(
    self,
    *,
    base_url: str = "",
    timeout: float = 10.0,
    retry_policy: RetryPolicy | None = None,
    failure_threshold: int = 5,
    recovery_seconds: float = 30.0,
    default_headers: Mapping[str, str] | None = None,
    verify_tls: bool = True,
    propagate_request_id: bool = True,
) -> None:
    """Initialize.

    Args:
        base_url (str): Prepended to relative paths. Use empty
            string to require absolute URLs at the call site.
        timeout (float): Per-request timeout in seconds.
            Overridable per call.
        retry_policy (RetryPolicy | None): Retry configuration.
            ``None`` uses the defaults (3 attempts, ~0.5/1/2s
            backoff).
        failure_threshold (int): Consecutive 5xx/network errors
            that trip the circuit per host. ``0`` disables the
            breaker.
        recovery_seconds (float): Seconds the breaker stays
            open before allowing one half-open probe.
        default_headers (Mapping[str, str] | None): Headers
            attached to every request (e.g. ``Authorization``).
        verify_tls (bool): Whether to verify TLS certificates.
            Default ``True`` — flip only for internal mTLS or
            dev with self-signed certs.
        propagate_request_id (bool): When ``True`` (default),
            attach ``X-Request-ID`` from the current
            contextvar to outbound requests.

    Raises:
        ImportError: When the ``[http]`` extra is missing.
    """
    if _httpx_mod is None:
        raise ImportError(
            "HTTPClient requires the [http] extra. "
            "Install with `pip install tempest-fastapi-sdk[http]`."
        )
    self.base_url: str = base_url
    self.timeout: float = timeout
    self.retry_policy: RetryPolicy = retry_policy or RetryPolicy()
    self.failure_threshold: int = failure_threshold
    self.recovery_seconds: float = recovery_seconds
    self.propagate_request_id: bool = propagate_request_id
    self._client: httpx.AsyncClient = _httpx_mod.AsyncClient(
        base_url=base_url,
        timeout=_httpx_mod.Timeout(timeout, connect=5.0),
        headers=dict(default_headers or {}),
        verify=verify_tls,
    )
    self._breakers: dict[str, _BreakerState] = {}
    self._lock: asyncio.Lock = asyncio.Lock()

JWTUtils

JWTUtils(
    secret: str,
    *,
    algorithm: str = "HS256",
    default_ttl: timedelta = timedelta(hours=1),
    issuer: str | None = None,
)

Encode and decode JWTs using a shared secret.

Every token gets an iat (issued-at) and exp (expiry) claim populated automatically; the caller is responsible for the rest (sub, custom claims, etc.). When the helper is created with issuer=, the iss claim is also added on encode and verified on decode.

Attributes:

Name Type Description
algorithm str

The JWT signing algorithm.

default_ttl timedelta

Default expiration applied on :meth:encode when ttl is not provided.

Initialize.

Parameters:

Name Type Description Default
secret str

The signing key (HMAC) or private key (RSA/EC).

required
algorithm str

JWT algorithm. Defaults to "HS256". Use "RS256" / "ES256" for asymmetric setups.

'HS256'
default_ttl timedelta

TTL applied by :meth:encode when the caller doesn't pass one. Defaults to 1 hour.

timedelta(hours=1)
issuer str | None

Value for the iss claim. When set, :meth:decode rejects tokens whose iss doesn't match (i.e. domain-level isolation).

None

Raises:

Type Description
ImportError

When the [auth] extra is not installed.

Source code in tempest_fastapi_sdk/utils/jwt.py
def __init__(
    self,
    secret: str,
    *,
    algorithm: str = "HS256",
    default_ttl: timedelta = timedelta(hours=1),
    issuer: str | None = None,
) -> None:
    """Initialize.

    Args:
        secret (str): The signing key (HMAC) or private key (RSA/EC).
        algorithm (str): JWT algorithm. Defaults to ``"HS256"``.
            Use ``"RS256"`` / ``"ES256"`` for asymmetric setups.
        default_ttl (timedelta): TTL applied by :meth:`encode` when
            the caller doesn't pass one. Defaults to 1 hour.
        issuer (str | None): Value for the ``iss`` claim. When set,
            :meth:`decode` rejects tokens whose ``iss`` doesn't
            match (i.e. domain-level isolation).

    Raises:
        ImportError: When the ``[auth]`` extra is not installed.
    """
    if _jwt is None:
        raise ImportError(
            "JWTUtils requires the [auth] extra. "
            "Install with `pip install tempest-fastapi-sdk[auth]`."
        )
    self._secret: str = secret
    self.algorithm: str = algorithm
    self.default_ttl: timedelta = default_ttl
    self._issuer: str | None = issuer

LocalUploadStorage

LocalUploadStorage(base_dir: Path | str)

Disk-backed :class:UploadStorage using aiofiles.

Writes chunks under base_dir and refuses keys that resolve outside the base — same path-traversal protection :class:UploadUtils already applied. The base_dir is created (with parents) on instantiation.

Initialize.

Parameters:

Name Type Description Default
base_dir Path | str

Root directory for all writes.

required

Raises:

Type Description
ImportError

When the [upload] extra is not installed.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
def __init__(self, base_dir: Path | str) -> None:
    """Initialize.

    Args:
        base_dir (Path | str): Root directory for all writes.

    Raises:
        ImportError: When the ``[upload]`` extra is not
            installed.
    """
    if _aiofiles is None:
        raise ImportError(
            "LocalUploadStorage requires the [upload] extra. "
            "Install with `pip install tempest-fastapi-sdk[upload]`."
        )
    self.base_dir: Path = Path(base_dir).resolve()
    self.base_dir.mkdir(parents=True, exist_ok=True)

LogUtils

LogUtils(
    name: str,
    *,
    level: str | int = "INFO",
    json_output: bool = True,
    log_dir: str | Path | None = "logs",
    stdout: bool = True,
    file_output: bool = True,
)

High-level logging facade used across SDK consumers.

Wraps :func:tempest_fastapi_sdk.configure_logging so callers can obtain a fully configured JSON logger with one line, and exposes structured info/warning/error/debug/exception methods that forward **fields as top-level keys on the JSON payload via Python's logging.LogRecord.extra.

The class can be used in two flavors:

  • Instance API — keeps a configured logger as state and exposes level methods directly. Recommended for service-wide singletons.
  • Static helpers — :meth:configure and :meth:get_logger for ad-hoc configuration without tying state to an object.

Attributes:

Name Type Description
logger Logger

The configured stdlib logger.

name str

The logger name.

Configure and bind a logger to this instance.

Mirrors :func:configure_logging defaults — stdout and file output are enabled out of the box, writing under logs/.

Parameters:

Name Type Description Default
name str

Logger name. Typically __name__ of the root module, or the service name.

required
level str | int

Minimum log level to emit. Accepts stdlib names ("INFO", "DEBUG") or integers.

'INFO'
json_output bool

When True (default), structured JSON output via :class:JSONFormatter. When False, a human-readable text formatter.

True
log_dir str | Path | None

Directory for per-level files. Defaults to "logs". Pass None to disable file logging.

'logs'
stdout bool

Attach the stdout handler. Defaults to True.

True
file_output bool

Attach the per-level + 500.log file handlers under log_dir. Defaults to True.

True
Source code in tempest_fastapi_sdk/utils/log.py
def __init__(
    self,
    name: str,
    *,
    level: str | int = "INFO",
    json_output: bool = True,
    log_dir: str | Path | None = "logs",
    stdout: bool = True,
    file_output: bool = True,
) -> None:
    """Configure and bind a logger to this instance.

    Mirrors :func:`configure_logging` defaults — stdout *and* file
    output are enabled out of the box, writing under ``logs/``.

    Args:
        name (str): Logger name. Typically ``__name__`` of the
            root module, or the service name.
        level (str | int): Minimum log level to emit. Accepts
            stdlib names (``"INFO"``, ``"DEBUG"``) or integers.
        json_output (bool): When ``True`` (default), structured
            JSON output via :class:`JSONFormatter`. When ``False``,
            a human-readable text formatter.
        log_dir (str | Path | None): Directory for per-level files.
            Defaults to ``"logs"``. Pass ``None`` to disable file
            logging.
        stdout (bool): Attach the stdout handler. Defaults to
            ``True``.
        file_output (bool): Attach the per-level + ``500.log`` file
            handlers under ``log_dir``. Defaults to ``True``.
    """
    self.name: str = name
    self.logger: logging.Logger = configure_logging(
        level=level,
        json_output=json_output,
        logger_name=name,
        log_dir=log_dir,
        stdout=stdout,
        file_output=file_output,
    )

MemoryMetrics dataclass

MemoryMetrics(total_bytes: int, used_bytes: int, available_bytes: int, percent: float)

RAM usage snapshot.

Attributes:

Name Type Description
total_bytes int

Total physical memory.

used_bytes int

Memory actively in use.

available_bytes int

Memory available for new allocations.

percent float

Used percentage (0-100).

MetricsUtils

Aggregated CPU/RAM/disk/GPU readings for the current host.

Built on top of :mod:psutil (always required by the [metrics] extra) and pynvml (optional — NVIDIA GPU support degrades to an empty list when the library is missing or no NVIDIA device is present).

Every method has a synchronous and an asynchronous variant. Sync methods call :mod:psutil directly (most calls are non-blocking or block briefly for sampling); async variants run the same code via :func:asyncio.to_thread so they never stall the event loop when a longer sampling interval is requested.

Stateless — instantiation is unnecessary; every method is a classmethod.

MinIOUploadStorage

MinIOUploadStorage(client: AsyncMinIOClient, *, bucket: str | None = None)

:class:UploadStorage backed by :class:AsyncMinIOClient.

Reuses an existing client instance — typically the one wired on the FastAPI app — so the connection pool is shared. The bucket falls back to the client's default_bucket.

Initialize.

Parameters:

Name Type Description Default
client AsyncMinIOClient

A configured MinIO client.

required
bucket str | None

Target bucket. None uses the client's default_bucket.

None
Source code in tempest_fastapi_sdk/utils/storage_backends.py
def __init__(
    self,
    client: AsyncMinIOClient,
    *,
    bucket: str | None = None,
) -> None:
    """Initialize.

    Args:
        client (AsyncMinIOClient): A configured MinIO client.
        bucket (str | None): Target bucket. ``None`` uses the
            client's ``default_bucket``.
    """
    self.client: AsyncMinIOClient = client
    self.bucket: str | None = bucket

PasswordUtils

PasswordUtils(*, rounds: int = 12)

Hash and verify passwords using bcrypt.

Stateless utility — instantiate once and reuse across the application. The cost factor (rounds) controls how slow hashing is; 12 is a sensible 2026 default. Raise it when CPU budget allows to keep up with hardware.

Attributes:

Name Type Description
rounds int

The bcrypt cost factor.

Initialize.

Parameters:

Name Type Description Default
rounds int

The bcrypt cost factor. Higher values make hashing slower and brute-force attacks harder. Defaults to 12.

12

Raises:

Type Description
ImportError

When the [auth] extra is not installed.

Source code in tempest_fastapi_sdk/utils/password.py
def __init__(self, *, rounds: int = 12) -> None:
    """Initialize.

    Args:
        rounds (int): The bcrypt cost factor. Higher values make
            hashing slower and brute-force attacks harder.
            Defaults to ``12``.

    Raises:
        ImportError: When the ``[auth]`` extra is not installed.
    """
    if _bcrypt is None:
        raise ImportError(
            "PasswordUtils requires the [auth] extra. "
            "Install with `pip install tempest-fastapi-sdk[auth]`."
        )
    self.rounds: int = rounds

RetryPolicy dataclass

RetryPolicy(
    max_attempts: int = 3,
    backoff_initial_seconds: float = 0.5,
    backoff_max_seconds: float = 8.0,
    retry_statuses: frozenset[int] = (lambda: frozenset({429, 500, 502, 503, 504}))(),
)

Bounded exponential backoff for retried requests.

The first retry sleeps for backoff_initial_seconds; each subsequent retry doubles the wait, capped at backoff_max_seconds. Total retries are bounded by max_attempts (the first try counts).

Attributes:

Name Type Description
max_attempts int

Total tries including the first. 1 disables retries.

backoff_initial_seconds float

Sleep before the second attempt.

backoff_max_seconds float

Hard cap per sleep.

retry_statuses frozenset[int]

HTTP status codes worth retrying. Defaults to common 5xx; 429 is included because it usually means "back off and try again".

SystemMetrics dataclass

SystemMetrics(
    cpu: CPUMetrics,
    memory: MemoryMetrics,
    disks: list[DiskMetrics] = list(),
    gpus: list[GPUMetrics] = list(),
)

Full machine snapshot returned by :meth:MetricsUtils.snapshot.

Attributes:

Name Type Description
cpu CPUMetrics

CPU usage block.

memory MemoryMetrics

RAM usage block.

disks list[DiskMetrics]

One entry per inspected path.

gpus list[GPUMetrics]

One entry per detected NVIDIA GPU.

ThrottleBackend

Bases: Protocol

Minimal async key-value contract a throttle backend must satisfy.

Matches the relevant subset of redis.asyncio.Redis.

ThrottleStatus dataclass

ThrottleStatus(attempts: int, blocked: bool, retry_after_seconds: int)

Outcome of a throttle query.

Attributes:

Name Type Description
attempts int

Failures recorded in the current window.

blocked bool

Whether the attempt budget is exhausted.

retry_after_seconds int

Seconds until the window resets. 0 when not blocked.

UploadResult dataclass

UploadResult(key: str, size: int, path: Path | None = None, url: str | None = None)

Outcome of an :meth:UploadStorage.write_stream call.

The key is the canonical identifier for the persisted object — a path string for the local backend, an S3 key for MinIO. path is set only when the backend wrote to a real filesystem location; url only when the backend can mint a download URL (presigned or static).

Attributes:

Name Type Description
key str

Identifier used to read the object back.

size int

Bytes written.

path Path | None

On-disk path when applicable.

url str | None

Public or presigned download URL when applicable.

UploadStorage

Bases: Protocol

Protocol every upload backend implements.

Implementations must be safe to call concurrently — FastAPI routes share the same instance.

UploadUtils

UploadUtils(
    upload_dir: Path | str,
    *,
    max_size_bytes: int | None = None,
    allowed_extensions: set[str] | None = None,
    allowed_mimetypes: set[str] | None = None,
    verify_magic_bytes: bool = False,
    chunk_size: int = 1024 * 1024,
)

Persist uploaded files to local disk with opt-in validation.

Validation is incremental: extension and MIME type are checked against the configured whitelists before reading any bytes; the file's real content is optionally sniffed from its first bytes (verify_magic_bytes); and size is enforced as the stream is consumed so oversized uploads don't fill the disk before being rejected.

Saved files are streamed in chunks so memory usage stays bounded regardless of the upload size.

Attributes:

Name Type Description
upload_dir Path

Base directory where files are persisted. Created on instantiation when missing.

max_size_bytes int | None

Reject uploads larger than this. None disables the size check.

allowed_extensions set[str] | None

Whitelist of file extensions (lowercase, no dot). None disables the extension check.

allowed_mimetypes set[str] | None

Whitelist of MIME types (lowercase). None disables the MIME check.

verify_magic_bytes bool

When True, the first bytes of every upload are sniffed (:func:sniff_mime) and the detected type must be consistent with the declared type / allow-list. Defends against polyglots and content/MIME mismatches. Only enable when every accepted format is one :func:sniff_mime recognizes (images, PDF) — otherwise a legitimate but unsniffable upload is rejected.

Initialize.

Parameters:

Name Type Description Default
upload_dir Path | str

Base directory. Created if missing (parents included).

required
max_size_bytes int | None

Reject uploads larger than this. None disables the size check.

None
allowed_extensions set[str] | None

Whitelist of file extensions. Leading dots and case are normalized internally so {"PNG", ".jpg"} works as expected.

None
allowed_mimetypes set[str] | None

Whitelist of MIME types (case-insensitive, e.g. {"image/png"}).

None
verify_magic_bytes bool

Sniff the first bytes of each upload and reject content that does not match its declared type / the allow-list. See the class attribute docs for the caveat. Default False.

False
chunk_size int

Stream read chunk in bytes. Defaults to 1 MiB; raise to trade memory for fewer syscalls.

1024 * 1024

Raises:

Type Description
ImportError

When the [upload] extra is not installed.

Source code in tempest_fastapi_sdk/utils/upload.py
def __init__(
    self,
    upload_dir: Path | str,
    *,
    max_size_bytes: int | None = None,
    allowed_extensions: set[str] | None = None,
    allowed_mimetypes: set[str] | None = None,
    verify_magic_bytes: bool = False,
    chunk_size: int = 1024 * 1024,
) -> None:
    """Initialize.

    Args:
        upload_dir (Path | str): Base directory. Created if
            missing (parents included).
        max_size_bytes (int | None): Reject uploads larger than
            this. ``None`` disables the size check.
        allowed_extensions (set[str] | None): Whitelist of file
            extensions. Leading dots and case are normalized
            internally so ``{"PNG", ".jpg"}`` works as expected.
        allowed_mimetypes (set[str] | None): Whitelist of MIME
            types (case-insensitive, e.g. ``{"image/png"}``).
        verify_magic_bytes (bool): Sniff the first bytes of each
            upload and reject content that does not match its
            declared type / the allow-list. See the class
            attribute docs for the caveat. Default ``False``.
        chunk_size (int): Stream read chunk in bytes. Defaults to
            1 MiB; raise to trade memory for fewer syscalls.

    Raises:
        ImportError: When the ``[upload]`` extra is not installed.
    """
    if _aiofiles is None:
        raise ImportError(
            "UploadUtils requires the [upload] extra. "
            "Install with `pip install tempest-fastapi-sdk[upload]`."
        )
    self.upload_dir: Path = Path(upload_dir)
    self.upload_dir.mkdir(parents=True, exist_ok=True)
    self.max_size_bytes: int | None = max_size_bytes
    self.allowed_extensions: set[str] | None = (
        {ext.lower().lstrip(".") for ext in allowed_extensions}
        if allowed_extensions is not None
        else None
    )
    self.allowed_mimetypes: set[str] | None = (
        {mime.lower() for mime in allowed_mimetypes}
        if allowed_mimetypes is not None
        else None
    )
    self.verify_magic_bytes: bool = verify_magic_bytes
    self._chunk_size: int = chunk_size

WebPushDispatcher

WebPushDispatcher(
    vapid_private_key: str,
    *,
    vapid_subject: str,
    ttl_seconds: int = 60,
    extra_vapid_claims: dict[str, str] | None = None,
)

Send VAPID-signed Web Push notifications to browser subscribers.

Wraps the synchronous pywebpush library in :func:asyncio.to_thread so dispatch fits the SDK's async-first convention. Subscriptions that respond with 404/410 raise :class:WebPushGoneError so the caller can prune their store; every other failure raises :class:WebPushError.

Attributes:

Name Type Description
vapid_private_key str

VAPID private key (PEM or base64url encoded). MUST match the public key advertised to clients.

vapid_claims dict[str, str]

Mandatory JWT claims attached to every push. sub is required (typically mailto:ops@example.com).

ttl_seconds int

Default time-to-live applied to each push (the push service buffers the payload for at most this long when the device is offline).

Initialize the dispatcher.

Parameters:

Name Type Description Default
vapid_private_key str

VAPID private key.

required
vapid_subject str

The sub JWT claim. Browsers expect either a mailto: or https: URI.

required
ttl_seconds int

Default TTL for delivered messages.

60
extra_vapid_claims dict[str, str] | None

Additional claims merged into the JWT.

None
Source code in tempest_fastapi_sdk/webpush/dispatcher.py
def __init__(
    self,
    vapid_private_key: str,
    *,
    vapid_subject: str,
    ttl_seconds: int = 60,
    extra_vapid_claims: dict[str, str] | None = None,
) -> None:
    """Initialize the dispatcher.

    Args:
        vapid_private_key (str): VAPID private key.
        vapid_subject (str): The ``sub`` JWT claim. Browsers
            expect either a ``mailto:`` or ``https:`` URI.
        ttl_seconds (int): Default TTL for delivered messages.
        extra_vapid_claims (dict[str, str] | None): Additional
            claims merged into the JWT.
    """
    self.vapid_private_key: str = vapid_private_key
    self.vapid_claims: dict[str, str] = {"sub": vapid_subject}
    if extra_vapid_claims:
        self.vapid_claims.update(extra_vapid_claims)
    self.ttl_seconds: int = ttl_seconds

WebPushError

WebPushError(
    message: str, *, status_code: int | None = None, endpoint: str | None = None
)

Bases: RuntimeError

Raised when a push delivery attempt fails irrecoverably.

Attributes:

Name Type Description
status_code int | None

HTTP status returned by the push service, or None when the failure happened before the request was made.

endpoint str | None

The subscription endpoint, when known.

Initialize the error.

Parameters:

Name Type Description Default
message str

Human-readable description.

required
status_code int | None

HTTP status, when known.

None
endpoint str | None

Subscription endpoint, when known.

None
Source code in tempest_fastapi_sdk/webpush/dispatcher.py
def __init__(
    self,
    message: str,
    *,
    status_code: int | None = None,
    endpoint: str | None = None,
) -> None:
    """Initialize the error.

    Args:
        message (str): Human-readable description.
        status_code (int | None): HTTP status, when known.
        endpoint (str | None): Subscription endpoint, when known.
    """
    super().__init__(message)
    self.status_code: int | None = status_code
    self.endpoint: str | None = endpoint

WebPushGoneError

WebPushGoneError(
    message: str, *, status_code: int | None = None, endpoint: str | None = None
)

Bases: WebPushError

Raised when the push service reports the subscription is gone.

Maps to HTTP 404/410. Receivers should delete the subscription from their store and stop attempting delivery.

Source code in tempest_fastapi_sdk/webpush/dispatcher.py
def __init__(
    self,
    message: str,
    *,
    status_code: int | None = None,
    endpoint: str | None = None,
) -> None:
    """Initialize the error.

    Args:
        message (str): Human-readable description.
        status_code (int | None): HTTP status, when known.
        endpoint (str | None): Subscription endpoint, when known.
    """
    super().__init__(message)
    self.status_code: int | None = status_code
    self.endpoint: str | None = endpoint

WebPushKeysSchema

Bases: BaseSchema

The keys object returned by PushSubscription.toJSON().

Browsers expose the encryption material as two URL-safe base64 strings; the SDK keeps the wire names so subscriptions can be stored verbatim and replayed on dispatch.

Attributes:

Name Type Description
p256dh str

Client public ECDH key (URL-safe base64).

auth str

Client auth secret (URL-safe base64).

WebPushPayloadSchema

Bases: BaseSchema

Optional helper for the JSON payload delivered with each push.

Mirrors the Notification API options exposed in service workers; callers that want stricter typing can subclass this for their application-specific event types.

Attributes:

Name Type Description
title str | None

Notification title shown to the user.

body str | None

Notification body.

icon str | None

URL of the icon to display.

badge str | None

URL of the badge icon (Android).

tag str | None

Tag used to coalesce notifications.

data dict[str, Any] | None

Arbitrary application payload.

actions list[dict[str, Any]] | None

Action button specs.

WebPushSubscriptionSchema

Bases: BaseSchema

Server-side representation of PushSubscription.toJSON().

Two browser-flavored field names (expirationTime) are exposed via aliases so the schema round-trips JSON produced by JSON.stringify(subscription) without manual key mangling.

Attributes:

Name Type Description
endpoint str

Push service endpoint URL.

keys WebPushKeysSchema

Encryption keys.

expiration_time int | None

Optional expiration timestamp in milliseconds since epoch. Aliased to expirationTime on the wire.

WebSocketConnection dataclass

WebSocketConnection(
    connection_id: UUID, user_id: UUID, ws: WebSocket, topics: set[str] = set()
)

A single live WebSocket bound to an authenticated user.

Attributes:

Name Type Description
connection_id UUID

Unique identifier; the hub keys connections by this so the same user can hold several sockets at once (e.g. multi-tab).

user_id UUID

The user the connection belongs to. Set by WebSocketHub.register based on whatever the bearer resolver returns.

ws WebSocket

The underlying FastAPI/Starlette socket.

topics set[str]

Set of topic strings the connection has subscribed to. Populated by :meth:WebSocketHub.subscribe.

WebSocketHub

WebSocketHub(*, max_per_user: int = 5)

In-process registry of live WebSocket connections.

Tracks every connection accepted by :func:tempest_fastapi_sdk.make_websocket_router and offers three delivery patterns:

  • send_to(user_id, envelope) — every socket the user has open right now.
  • broadcast(envelope, topic=...) — every subscriber of topic (or every connection when topic is omitted).
  • subscribe(connection_id, topic) / unsubscribe(connection_id, topic) — per-connection topic membership.

This hub is single-process. For multi-replica deployments, fan out across processes via a pub/sub backend (Redis pub/sub, RabbitMQ topic exchange) — the hub itself only handles in-process delivery. The future RedisWebSocketHub will swap the local broadcast for a redis-driven one without changing the public surface; today, run a single replica or use sticky sessions when WebSocket fan-out matters.

The hub is safe to share across handlers in the same FastAPI app — all mutators take an asyncio.Lock so concurrent register/unregister calls do not corrupt the internal state.

Initialize the hub.

Parameters:

Name Type Description Default
max_per_user int

Cap on concurrent connections per user. When the cap is hit on register, the oldest connection for that user is force-closed with code 4429 and removed before the new one is registered.

5
Source code in tempest_fastapi_sdk/websockets/hub.py
def __init__(self, *, max_per_user: int = 5) -> None:
    """Initialize the hub.

    Args:
        max_per_user (int): Cap on concurrent connections per
            user. When the cap is hit on ``register``, the
            oldest connection for that user is force-closed
            with code ``4429`` and removed before the new one
            is registered.
    """
    self._connections: dict[UUID, WebSocketConnection] = {}
    self._by_user: dict[UUID, list[UUID]] = {}
    self._by_topic: dict[str, set[UUID]] = {}
    self._lock: asyncio.Lock = asyncio.Lock()
    self.max_per_user: int = max_per_user

WSEnvelope

Bases: BaseSchema

Canonical message envelope for the bundled WebSocket router.

Every frame the SDK sends — application data, heartbeats, errors — fits this shape so clients can dispatch on type alone. Senders on the consumer side are encouraged to use it too, but the router accepts any JSON payload and only the heartbeat frames it owns are strictly required to follow this schema.

Attributes:

Name Type Description
type str

Event name. Reserved values: "ping" / "pong" (heartbeat).

data dict[str, Any]

Payload — empty dict when none.

request_id str | None

Echoes the originating HTTP request-id for end-to-end tracing across SSE/HTTP/WS.


Banco de dados

tempest_fastapi_sdk.db

BaseModel

Bases: AsyncAttrs, DeclarativeBase

Abstract base for every SQLAlchemy model in the application.

Every concrete model inherits the four columns required by the SDK conventions: id (UUID primary key, cross-DB portable), is_active (soft-delete flag), created_at and updated_at (timezone-aware timestamps managed by the database).

The class is marked __abstract__ so SQLAlchemy will not try to map it directly. Concrete subclasses get an auto-generated __tablename__ from their class name (e.g. UserModeluser, OrderItemModelorder_item); the Model suffix is stripped and the remainder is snake-cased. Explicit __tablename__ declarations still win when set.

Equality and hashing use (type, id) so the same row loaded across different sessions compares equal — useful in tests and sets. Unflushed instances (id is None) fall back to Python identity.

Attributes:

Name Type Description
metadata MetaData

Configured with :data:NAMING_CONVENTION so Alembic generates deterministic constraint names.

id UUID

Primary key. Generated as UUID v4. Uses sqlalchemy.Uuid so the same Python type works against PostgreSQL, MySQL, SQLite and MSSQL.

is_active bool

Whether the record is active. Defaults to True.

created_at datetime

Creation timestamp populated by the database on insert.

updated_at datetime

Last-update timestamp refreshed by the database on every update.

to_dict

to_dict(
    exclude: list[str] | None = None,
    include: dict[str, Any] | None = None,
    remove_none: bool = False,
) -> dict[str, Any]

Serialize the row to a plain dict.

Parameters:

Name Type Description Default
exclude list[str] | None

Column names to drop from the output.

None
include dict[str, Any] | None

Extra entries to merge into the output.

None
remove_none bool

Whether to strip keys whose value is None.

False

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The serialized representation.

Source code in tempest_fastapi_sdk/db/model.py
def to_dict(
    self,
    exclude: list[str] | None = None,
    include: dict[str, Any] | None = None,
    remove_none: bool = False,
) -> dict[str, Any]:
    """Serialize the row to a plain ``dict``.

    Args:
        exclude (list[str] | None): Column names to drop from
            the output.
        include (dict[str, Any] | None): Extra entries to merge
            into the output.
        remove_none (bool): Whether to strip keys whose value
            is ``None``.

    Returns:
        dict[str, Any]: The serialized representation.
    """
    column_attrs = inspect(self.__class__).mapper.column_attrs
    data: dict[str, Any] = {
        attr.key: getattr(self, attr.key) for attr in column_attrs
    }
    if remove_none:
        data = {key: value for key, value in data.items() if value is not None}
    return modify_dict(data, exclude=exclude, include=include)

update_from_dict

update_from_dict(data: dict[str, Any], allowed_fields: set[str] | None = None) -> None

Bulk-assign attributes from a dict.

Unknown keys are silently ignored — only mapped columns of self.__class__ are eligible. When allowed_fields is provided, only the intersection with model columns is applied; this is the recommended way to consume external payloads (e.g. PATCH bodies) so callers can't write to sensitive columns like id or role by mistake.

Parameters:

Name Type Description Default
data dict[str, Any]

The payload (typically schema.to_dict()).

required
allowed_fields set[str] | None

Whitelist of column names; only these keys are applied. None accepts every mapped column (use only for trusted payloads).

None
Source code in tempest_fastapi_sdk/db/model.py
def update_from_dict(
    self,
    data: dict[str, Any],
    allowed_fields: set[str] | None = None,
) -> None:
    """Bulk-assign attributes from a dict.

    Unknown keys are silently ignored — only mapped columns of
    ``self.__class__`` are eligible. When ``allowed_fields`` is
    provided, only the intersection with model columns is
    applied; this is the recommended way to consume external
    payloads (e.g. PATCH bodies) so callers can't write to
    sensitive columns like ``id`` or ``role`` by mistake.

    Args:
        data (dict[str, Any]): The payload (typically
            ``schema.to_dict()``).
        allowed_fields (set[str] | None): Whitelist of column
            names; only these keys are applied. ``None`` accepts
            every mapped column (use only for trusted payloads).
    """
    columns = {c.key for c in inspect(self.__class__).mapper.column_attrs}
    keys = (allowed_fields & columns) if allowed_fields is not None else columns
    for key, value in data.items():
        if key in keys:
            setattr(self, key, value)

BaseUserModel

Bases: BaseModel

Abstract user table with the columns the admin auth flow needs.

Inherits id/is_active/created_at/updated_at from :class:BaseModel and adds:

  • email (unique, indexed) — login identifier. Always stored lowercased; helpers handle the normalization.
  • hashed_password — bcrypt hash produced by :class:tempest_fastapi_sdk.PasswordUtils. Use :meth:set_password to write and :meth:check_password to verify so the hashing strategy stays consistent across callers.
  • is_admin — gate enforced by the admin auth backend; only users with is_admin=True may log in to /admin.
  • last_login_at — populated by the admin login view on every successful authentication.

The class is marked __abstract__ so SQLAlchemy does not try to map it directly; concrete projects subclass it and either keep the auto-derived __tablename__ (user) or override it.

Attributes:

Name Type Description
email str

Login identifier. Unique. 320 chars max (RFC 5321 mailbox limit).

hashed_password str

Bcrypt hash; never store plaintext.

is_admin bool

Whether the user can access the admin site.

last_login_at datetime | None

Last successful login timestamp.

set_password

set_password(plain: str, *, rounds: int = 12) -> None

Hash plain and write it to :attr:hashed_password.

Parameters:

Name Type Description Default
plain str

The plaintext password.

required
rounds int

bcrypt cost factor. Defaults to 12.

12

Raises:

Type Description
ImportError

When the [auth] extra is not installed.

Source code in tempest_fastapi_sdk/db/user_model.py
def set_password(self, plain: str, *, rounds: int = 12) -> None:
    """Hash ``plain`` and write it to :attr:`hashed_password`.

    Args:
        plain (str): The plaintext password.
        rounds (int): bcrypt cost factor. Defaults to ``12``.

    Raises:
        ImportError: When the ``[auth]`` extra is not installed.
    """
    self.hashed_password = PasswordUtils(rounds=rounds).hash(plain)

check_password

check_password(plain: str) -> bool

Return whether plain matches :attr:hashed_password.

Parameters:

Name Type Description Default
plain str

The plaintext password to verify.

required

Returns:

Name Type Description
bool bool

True when the password is correct.

Raises:

Type Description
ImportError

When the [auth] extra is not installed.

Source code in tempest_fastapi_sdk/db/user_model.py
def check_password(self, plain: str) -> bool:
    """Return whether ``plain`` matches :attr:`hashed_password`.

    Args:
        plain (str): The plaintext password to verify.

    Returns:
        bool: ``True`` when the password is correct.

    Raises:
        ImportError: When the ``[auth]`` extra is not installed.
    """
    if not self.hashed_password:
        return False
    return PasswordUtils().verify(plain, self.hashed_password)

normalize_email staticmethod

normalize_email(value: str) -> str

Trim whitespace and lowercase value.

Parameters:

Name Type Description Default
value str

Raw user input.

required

Returns:

Name Type Description
str str

The normalized email.

Source code in tempest_fastapi_sdk/db/user_model.py
@staticmethod
def normalize_email(value: str) -> str:
    """Trim whitespace and lowercase ``value``.

    Args:
        value (str): Raw user input.

    Returns:
        str: The normalized email.
    """
    return value.strip().lower()

BaseRepository

BaseRepository(
    session: AsyncSession,
    *,
    model: type[ModelType],
    not_found_exception: type[AppException] = NotFoundException,
    not_found_message: str | None = None,
    create_conflict_message: str | None = None,
    update_conflict_message: str | None = None,
    bulk_create_conflict_message: str | None = None,
    bulk_update_conflict_message: str | None = None,
)

Bases: Generic[ModelType]

Base async repository with generic CRUD operations.

Instantiate directly for plain CRUD (BaseRepository(session, model=UserModel)) or subclass when adding custom queries — the subclass forwards model / not_found_exception to super().__init__ instead of declaring class attributes. The constructor signature is the contract; there are no magic class attributes to override.

The default filter logic supports equality on every column plus the following conventions:

  • name (string) → case-insensitive ILIKE %value% search.
  • bool values → .is_(value) (correct SQL boolean check).
  • list values → .in_(values) membership.
  • date values → func.date(column) == value whole-day match.
  • start_in / end_in (date) → range filter against the model's date column when present, falling back to created_at.

All error messages can be customized per repository instance via the constructor kwargs (not_found_message, create_conflict_message, etc.); when omitted, sensible defaults derived from self.model.__name__ are used.

The same three abstract mappers map_to_schema / map_to_model / map_to_response are kept so concrete repositories own the translation between ORM rows and DTOs.

Attributes:

Name Type Description
model type[ModelType]

The SQLAlchemy model class operated on.

not_found_exception type[AppException]

Exception class raised when single-record lookups miss.

session AsyncSession

The async database session.

Initialize the repository.

Every *_message kwarg is optional — when not provided, the repository falls back to a generic message derived from the model class name (e.g. "User not found", "Conflict creating User").

Parameters:

Name Type Description Default
session AsyncSession

The async database session.

required
model type[ModelType]

The SQLAlchemy model class this repository operates on. Required.

required
not_found_exception type[AppException]

Exception class raised when single-record lookups miss. Defaults to :class:NotFoundException; pass a domain-specific subclass for richer 404 messages.

NotFoundException
not_found_message str | None

Message used when get, get_by_id, delete, soft_delete or restore find no matching record.

None
create_conflict_message str | None

Message used when add raises IntegrityError.

None
update_conflict_message str | None

Message used when update raises IntegrityError.

None
bulk_create_conflict_message str | None

Message used when add_all raises IntegrityError.

None
bulk_update_conflict_message str | None

Message used when update_many or bulk_update raises IntegrityError.

None

Raises:

Type Description
TypeError

When model is not a subclass of :class:BaseModel.

Source code in tempest_fastapi_sdk/db/repository.py
def __init__(
    self,
    session: AsyncSession,
    *,
    model: type[ModelType],
    not_found_exception: type[AppException] = NotFoundException,
    not_found_message: str | None = None,
    create_conflict_message: str | None = None,
    update_conflict_message: str | None = None,
    bulk_create_conflict_message: str | None = None,
    bulk_update_conflict_message: str | None = None,
) -> None:
    """Initialize the repository.

    Every ``*_message`` kwarg is optional — when not provided, the
    repository falls back to a generic message derived from the
    model class name (e.g. ``"User not found"``,
    ``"Conflict creating User"``).

    Args:
        session (AsyncSession): The async database session.
        model (type[ModelType]): The SQLAlchemy model class this
            repository operates on. Required.
        not_found_exception (type[AppException]): Exception class
            raised when single-record lookups miss. Defaults to
            :class:`NotFoundException`; pass a domain-specific
            subclass for richer 404 messages.
        not_found_message (str | None): Message used when ``get``,
            ``get_by_id``, ``delete``, ``soft_delete`` or
            ``restore`` find no matching record.
        create_conflict_message (str | None): Message used when
            ``add`` raises ``IntegrityError``.
        update_conflict_message (str | None): Message used when
            ``update`` raises ``IntegrityError``.
        bulk_create_conflict_message (str | None): Message used
            when ``add_all`` raises ``IntegrityError``.
        bulk_update_conflict_message (str | None): Message used
            when ``update_many`` or ``bulk_update`` raises
            ``IntegrityError``.

    Raises:
        TypeError: When ``model`` is not a subclass of
            :class:`BaseModel`.
    """
    if not isinstance(model, type) or not issubclass(model, BaseModel):
        raise TypeError(
            "BaseRepository `model` must be a subclass of BaseModel",
        )
    self.session: AsyncSession = session
    self.model: type[ModelType] = model
    self.not_found_exception: type[AppException] = not_found_exception
    name = self.model.__name__
    self._not_found_message: str = not_found_message or f"{name} not found"
    self._create_conflict_message: str = (
        create_conflict_message or f"Conflict creating {name}"
    )
    self._update_conflict_message: str = (
        update_conflict_message or f"Conflict updating {name}"
    )
    self._bulk_create_conflict_message: str = (
        bulk_create_conflict_message or f"Conflict creating {name} batch"
    )
    self._bulk_update_conflict_message: str = (
        bulk_update_conflict_message or f"Conflict updating {name} batch"
    )

get async

get(filters: dict[str, Any], for_update: bool = False) -> ModelType

Return the single record matching filters.

Parameters:

Name Type Description Default
filters dict[str, Any]

The column-value pairs.

required
for_update bool

Whether to acquire a row-level lock (SELECT ... FOR UPDATE). Defaults to False.

False

Returns:

Name Type Description
ModelType ModelType

The matching row.

Raises:

Type Description
AppException

self.not_found_exception with the configured not_found_message if no record matches the filters.

Source code in tempest_fastapi_sdk/db/repository.py
async def get(
    self,
    filters: dict[str, Any],
    for_update: bool = False,
) -> ModelType:
    """Return the single record matching ``filters``.

    Args:
        filters (dict[str, Any]): The column-value pairs.
        for_update (bool): Whether to acquire a row-level lock
            (``SELECT ... FOR UPDATE``). Defaults to ``False``.

    Returns:
        ModelType: The matching row.

    Raises:
        AppException: ``self.not_found_exception`` with the
            configured ``not_found_message`` if no record
            matches the filters.
    """
    instance = await self.get_or_none(filters, for_update=for_update)
    if instance is None:
        self._raise_not_found()
    return cast(ModelType, instance)

get_or_none async

get_or_none(filters: dict[str, Any], for_update: bool = False) -> ModelType | None

Return the single record matching filters or None.

Unlike :meth:get, never raises when nothing matches.

Parameters:

Name Type Description Default
filters dict[str, Any]

The column-value pairs.

required
for_update bool

Whether to acquire a row-level lock.

False

Returns:

Type Description
ModelType | None

ModelType | None: The matching row, or None.

Source code in tempest_fastapi_sdk/db/repository.py
async def get_or_none(
    self,
    filters: dict[str, Any],
    for_update: bool = False,
) -> ModelType | None:
    """Return the single record matching ``filters`` or ``None``.

    Unlike :meth:`get`, never raises when nothing matches.

    Args:
        filters (dict[str, Any]): The column-value pairs.
        for_update (bool): Whether to acquire a row-level lock.

    Returns:
        ModelType | None: The matching row, or ``None``.
    """
    query = select(self.model)
    query = self._apply_filters(query, filters)
    if for_update:
        query = query.with_for_update()
    result = await self.session.execute(query)
    instance = result.unique().scalars().one_or_none()
    return cast("ModelType | None", instance)

get_by_id async

get_by_id(id: UUID, for_update: bool = False) -> ModelType

Return the record with the given primary key.

Parameters:

Name Type Description Default
id UUID

The primary key to look up.

required
for_update bool

Whether to acquire a row-level lock.

False

Returns:

Name Type Description
ModelType ModelType

The matching row.

Raises:

Type Description
AppException

self.not_found_exception if no record with id exists.

Source code in tempest_fastapi_sdk/db/repository.py
async def get_by_id(
    self,
    id: UUID,
    for_update: bool = False,
) -> ModelType:
    """Return the record with the given primary key.

    Args:
        id (UUID): The primary key to look up.
        for_update (bool): Whether to acquire a row-level lock.

    Returns:
        ModelType: The matching row.

    Raises:
        AppException: ``self.not_found_exception`` if no record
            with ``id`` exists.
    """
    return await self.get({"id": id}, for_update=for_update)

exists async

exists(filters: dict[str, Any]) -> bool

Return whether at least one row matches filters.

Executes a SELECT 1 ... LIMIT 1 so the row is never fully loaded.

Parameters:

Name Type Description Default
filters dict[str, Any]

The filter conditions.

required

Returns:

Name Type Description
bool bool

True if at least one row matches.

Source code in tempest_fastapi_sdk/db/repository.py
async def exists(self, filters: dict[str, Any]) -> bool:
    """Return whether at least one row matches ``filters``.

    Executes a ``SELECT 1 ... LIMIT 1`` so the row is never
    fully loaded.

    Args:
        filters (dict[str, Any]): The filter conditions.

    Returns:
        bool: ``True`` if at least one row matches.
    """
    query = select(self.model.id)
    query = self._apply_filters(query, filters)
    query = query.limit(1)
    result = await self.session.execute(query)
    return result.scalar() is not None

first async

first(
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> ModelType | None

Return the first matching row or None.

Convenience wrapper around :meth:list for cases that only need one row but want to control ordering.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

The filter conditions.

None
order_by Any | None

A SQLAlchemy column expression to order by. None keeps insertion order.

None
ascending bool

Whether to order ascending.

True

Returns:

Type Description
ModelType | None

ModelType | None: The first matching row, or None.

Source code in tempest_fastapi_sdk/db/repository.py
async def first(
    self,
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> ModelType | None:
    """Return the first matching row or ``None``.

    Convenience wrapper around :meth:`list` for cases that only
    need one row but want to control ordering.

    Args:
        filters (dict[str, Any] | None): The filter conditions.
        order_by: A SQLAlchemy column expression to order by.
            ``None`` keeps insertion order.
        ascending (bool): Whether to order ascending.

    Returns:
        ModelType | None: The first matching row, or ``None``.
    """
    query = select(self.model)
    if filters:
        query = self._apply_filters(query, filters)
    if order_by is not None:
        query = query.order_by(order_by if ascending else order_by.desc())
    query = query.limit(1)
    result = await self.session.execute(query)
    instance = result.unique().scalars().one_or_none()
    return instance

list async

list(
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> list[ModelType]

Return every record matching filters.

Returns [] (never raises) when nothing matches, in line with the SDK collection convention.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

The filter conditions.

None
order_by Any | None

A SQLAlchemy column expression (e.g. MyModel.name). None keeps insertion order.

None
ascending bool

Whether to order ascending. Ignored when order_by is None.

True

Returns:

Type Description
list[ModelType]

list[ModelType]: The matching rows.

Source code in tempest_fastapi_sdk/db/repository.py
async def list(
    self,
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> list[ModelType]:
    """Return every record matching ``filters``.

    Returns ``[]`` (never raises) when nothing matches, in line
    with the SDK collection convention.

    Args:
        filters (dict[str, Any] | None): The filter conditions.
        order_by: A SQLAlchemy column expression (e.g.
            ``MyModel.name``). ``None`` keeps insertion order.
        ascending (bool): Whether to order ascending. Ignored
            when ``order_by`` is ``None``.

    Returns:
        list[ModelType]: The matching rows.
    """
    query = select(self.model)

    if filters:
        query = self._apply_filters(query, filters)

    if order_by is not None:
        query = query.order_by(order_by if ascending else order_by.desc())

    result = await self.session.execute(query)
    return list(result.unique().scalars().all())

paginate async

paginate(
    filters: dict[str, Any] | None = None,
    order_by: str | None = None,
    page: int = 1,
    page_size: int = 20,
    ascending: bool = True,
    query: Select[Any] | None = None,
) -> dict[str, Any]

Return a single page of records matching filters.

When order_by is None, falls back to self.model.created_at.desc(). The total count is computed from the same filtered (and possibly joined) query, so custom queries with joins still report a correct total.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

Filter conditions.

None
order_by str | None

Column name to order by, or None to fall back to created_at desc.

None
page int

The 1-indexed page number.

1
page_size int

The number of items per page.

20
ascending bool

Whether to order ascending. Ignored when order_by is None.

True
query Select[Any] | None

A pre-built Select; if None, defaults to select(self.model).

None

Returns:

Type Description
dict[str, Any]

dict[str, Any]: A mapping with keys items, total,

dict[str, Any]

page, size, pages.

Source code in tempest_fastapi_sdk/db/repository.py
async def paginate(
    self,
    filters: dict[str, Any] | None = None,
    order_by: str | None = None,
    page: int = 1,
    page_size: int = 20,
    ascending: bool = True,
    query: Select[Any] | None = None,
) -> dict[str, Any]:
    """Return a single page of records matching ``filters``.

    When ``order_by`` is ``None``, falls back to
    ``self.model.created_at.desc()``. The total count is computed
    from the same filtered (and possibly joined) query, so custom
    queries with joins still report a correct total.

    Args:
        filters (dict[str, Any] | None): Filter conditions.
        order_by (str | None): Column name to order by, or
            ``None`` to fall back to ``created_at desc``.
        page (int): The 1-indexed page number.
        page_size (int): The number of items per page.
        ascending (bool): Whether to order ascending. Ignored
            when ``order_by`` is ``None``.
        query (Select[Any] | None): A pre-built ``Select``; if
            ``None``, defaults to ``select(self.model)``.

    Returns:
        dict[str, Any]: A mapping with keys ``items``, ``total``,
        ``page``, ``size``, ``pages``.
    """
    if query is None:
        query = select(self.model)

    if filters:
        query = self._apply_filters(query, filters)

    if order_by is None:
        query = query.order_by(self.model.created_at.desc())
    else:
        column = getattr(self.model, order_by)
        query = query.order_by(column if ascending else column.desc())

    count_query = select(func.count()).select_from(query.subquery())

    total_result = await self.session.execute(count_query)
    total = total_result.scalar() or 0

    offset = (page - 1) * page_size
    query = query.offset(offset).limit(page_size)

    result = await self.session.execute(query)
    items = list(result.unique().scalars().all())

    pages = (total + page_size - 1) // page_size

    return {
        "items": items,
        "total": total,
        "page": page,
        "page_size": page_size,
        "pages": pages,
    }

cursor_paginate async

cursor_paginate(
    filters: dict[str, Any] | None = None,
    cursor: str | None = None,
    limit: int = 20,
    order_by: str = "created_at",
    ascending: bool = False,
) -> dict[str, Any]

Return a single cursor-paginated page of records.

Cursor pagination orders by (order_by, id) so the result is stable under concurrent inserts and scales without a COUNT(*). The cursor encodes the last row's (order_by_value, id) so the next page can continue precisely past it.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

Filter conditions.

None
cursor str | None

Opaque cursor from the previous page; None requests the first page.

None
limit int

Maximum items to return in this page.

20
order_by str

Column to sort by. Must exist on the model.

'created_at'
ascending bool

Whether to sort ascending. Defaults to False.

False

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Mapping with items, next_cursor,

dict[str, Any]

has_more and limit.

Raises:

Type Description
ValueError

When order_by is not a column on the model, or when cursor is malformed.

Source code in tempest_fastapi_sdk/db/repository.py
async def cursor_paginate(
    self,
    filters: dict[str, Any] | None = None,
    cursor: str | None = None,
    limit: int = 20,
    order_by: str = "created_at",
    ascending: bool = False,
) -> dict[str, Any]:
    """Return a single cursor-paginated page of records.

    Cursor pagination orders by ``(order_by, id)`` so the result
    is stable under concurrent inserts and scales without a
    ``COUNT(*)``. The cursor encodes the last row's
    ``(order_by_value, id)`` so the next page can continue
    precisely past it.

    Args:
        filters (dict[str, Any] | None): Filter conditions.
        cursor (str | None): Opaque cursor from the previous page;
            ``None`` requests the first page.
        limit (int): Maximum items to return in this page.
        order_by (str): Column to sort by. Must exist on the model.
        ascending (bool): Whether to sort ascending. Defaults to
            ``False``.

    Returns:
        dict[str, Any]: Mapping with ``items``, ``next_cursor``,
        ``has_more`` and ``limit``.

    Raises:
        ValueError: When ``order_by`` is not a column on the
            model, or when ``cursor`` is malformed.
    """
    from tempest_fastapi_sdk.schemas.pagination import (
        decode_cursor,
        encode_cursor,
    )

    column = getattr(self.model, order_by, None)
    if column is None:
        raise ValueError(
            f"{self.model.__name__!r} has no column {order_by!r}",
        )

    query = select(self.model)
    if filters:
        query = self._apply_filters(query, filters)

    if cursor is not None:
        payload = decode_cursor(cursor)
        last_value = payload.get("value")
        last_id_raw = payload.get("id")
        try:
            last_id = (
                UUID(last_id_raw) if isinstance(last_id_raw, str) else last_id_raw
            )
        except (ValueError, AttributeError) as exc:
            raise ValueError("Invalid cursor id") from exc
        if ascending:
            query = query.where(
                (column > last_value)
                | ((column == last_value) & (self.model.id > last_id)),
            )
        else:
            query = query.where(
                (column < last_value)
                | ((column == last_value) & (self.model.id < last_id)),
            )

    primary = column if ascending else column.desc()
    secondary = self.model.id if ascending else self.model.id.desc()
    query = query.order_by(primary, secondary).limit(limit + 1)

    result = await self.session.execute(query)
    rows = list(result.unique().scalars().all())
    has_more = len(rows) > limit
    items = rows[:limit]

    next_cursor: str | None = None
    if has_more and items:
        last = items[-1]
        next_cursor = encode_cursor(
            {
                "value": getattr(last, order_by),
                "id": last.id,
            },
        )

    return {
        "items": items,
        "next_cursor": next_cursor,
        "has_more": has_more,
        "limit": limit,
    }

add async

add(model: ModelType) -> ModelType

Insert model into the database.

Parameters:

Name Type Description Default
model ModelType

The instance to insert.

required

Returns:

Name Type Description
ModelType ModelType

The same instance after refresh so the

ModelType

id and timestamp columns are populated.

Raises:

Type Description
ConflictException

On integrity violations (unique constraint, FK error, etc.).

Source code in tempest_fastapi_sdk/db/repository.py
async def add(self, model: ModelType) -> ModelType:
    """Insert ``model`` into the database.

    Args:
        model (ModelType): The instance to insert.

    Returns:
        ModelType: The same instance after ``refresh`` so the
        ``id`` and timestamp columns are populated.

    Raises:
        ConflictException: On integrity violations (unique
            constraint, FK error, etc.).
    """
    try:
        self.session.add(model)
        await self.session.commit()
        await self.session.refresh(model)
        return model
    except IntegrityError as exc:
        await self.session.rollback()
        logger.warning(
            "IntegrityError on %s.add: %s", self.model.__name__, exc.orig
        )
        raise ConflictException(
            message=self._create_conflict_message,
        ) from exc
    except Exception:
        await self.session.rollback()
        raise

add_all async

add_all(models: List[ModelType]) -> List[ModelType]

Insert several models in a single transaction.

Parameters:

Name Type Description Default
models list[ModelType]

The instances to insert.

required

Returns:

Type Description
List[ModelType]

list[ModelType]: The same list after every instance is

List[ModelType]

refreshed.

Raises:

Type Description
ConflictException

On integrity violations.

Source code in tempest_fastapi_sdk/db/repository.py
async def add_all(self, models: List[ModelType]) -> List[ModelType]:
    """Insert several models in a single transaction.

    Args:
        models (list[ModelType]): The instances to insert.

    Returns:
        list[ModelType]: The same list after every instance is
        refreshed.

    Raises:
        ConflictException: On integrity violations.
    """
    try:
        self.session.add_all(models)
        await self.session.commit()
        for model in models:
            await self.session.refresh(model)
        return models
    except IntegrityError as exc:
        await self.session.rollback()
        logger.warning(
            "IntegrityError on %s.add_all: %s", self.model.__name__, exc.orig
        )
        raise ConflictException(
            message=self._bulk_create_conflict_message,
        ) from exc
    except Exception:
        await self.session.rollback()
        raise

update async

update(model: ModelType) -> ModelType

Persist mutations made on an attached model.

The instance must already be tracked by the session (e.g. returned by :meth:get) with its fields modified. This method only commits and refreshes.

Parameters:

Name Type Description Default
model ModelType

The mutated instance.

required

Returns:

Name Type Description
ModelType ModelType

The same instance after refresh.

Raises:

Type Description
ConflictException

On integrity violations.

Source code in tempest_fastapi_sdk/db/repository.py
async def update(self, model: ModelType) -> ModelType:
    """Persist mutations made on an attached ``model``.

    The instance must already be tracked by the session (e.g.
    returned by :meth:`get`) with its fields modified. This
    method only commits and refreshes.

    Args:
        model (ModelType): The mutated instance.

    Returns:
        ModelType: The same instance after ``refresh``.

    Raises:
        ConflictException: On integrity violations.
    """
    try:
        await self.session.commit()
        await self.session.refresh(model)
        return model
    except IntegrityError as exc:
        await self.session.rollback()
        logger.warning(
            "IntegrityError on %s.update: %s", self.model.__name__, exc.orig
        )
        raise ConflictException(
            message=self._update_conflict_message,
        ) from exc
    except Exception:
        await self.session.rollback()
        raise

update_many async

update_many(models: List[ModelType]) -> List[ModelType]

Commit several mutated instances in a single transaction.

Parameters:

Name Type Description Default
models list[ModelType]

The mutated instances.

required

Returns:

Type Description
List[ModelType]

list[ModelType]: The same list.

Raises:

Type Description
ConflictException

On integrity violations.

Source code in tempest_fastapi_sdk/db/repository.py
async def update_many(self, models: List[ModelType]) -> List[ModelType]:
    """Commit several mutated instances in a single transaction.

    Args:
        models (list[ModelType]): The mutated instances.

    Returns:
        list[ModelType]: The same list.

    Raises:
        ConflictException: On integrity violations.
    """
    try:
        await self.session.commit()
        return models
    except IntegrityError as exc:
        await self.session.rollback()
        logger.warning(
            "IntegrityError on %s.update_many: %s",
            self.model.__name__,
            exc.orig,
        )
        raise ConflictException(
            message=self._bulk_update_conflict_message,
        ) from exc
    except Exception:
        await self.session.rollback()
        raise

bulk_update async

bulk_update(filters: dict[str, Any], values: dict[str, Any]) -> int

Issue a single UPDATE ... WHERE against the table.

Bypasses the unit-of-work entirely — useful for mass mutations that don't need to refresh each affected row in the session.

Parameters:

Name Type Description Default
filters dict[str, Any]

Filter conditions identifying the rows to mutate. An empty mapping is rejected to prevent accidental table-wide updates.

required
values dict[str, Any]

Column-value pairs to set on the matching rows.

required

Returns:

Name Type Description
int int

The number of rows affected.

Raises:

Type Description
ValueError

If filters is empty.

ConflictException

On integrity violations.

Source code in tempest_fastapi_sdk/db/repository.py
async def bulk_update(
    self,
    filters: dict[str, Any],
    values: dict[str, Any],
) -> int:
    """Issue a single ``UPDATE ... WHERE`` against the table.

    Bypasses the unit-of-work entirely — useful for mass mutations
    that don't need to refresh each affected row in the session.

    Args:
        filters (dict[str, Any]): Filter conditions identifying
            the rows to mutate. An empty mapping is rejected to
            prevent accidental table-wide updates.
        values (dict[str, Any]): Column-value pairs to set on the
            matching rows.

    Returns:
        int: The number of rows affected.

    Raises:
        ValueError: If ``filters`` is empty.
        ConflictException: On integrity violations.
    """
    if not filters:
        raise ValueError(
            "bulk_update requires non-empty filters; "
            "pass an explicit truthy condition to update every row."
        )
    try:
        query = update(self.model)
        query = self._apply_filters(query, filters)
        query = query.values(**values)
        result = cast(CursorResult[Any], await self.session.execute(query))
        await self.session.commit()
        return result.rowcount or 0
    except IntegrityError as exc:
        await self.session.rollback()
        logger.warning(
            "IntegrityError on %s.bulk_update: %s",
            self.model.__name__,
            exc.orig,
        )
        raise ConflictException(
            message=self._bulk_update_conflict_message,
        ) from exc
    except Exception:
        await self.session.rollback()
        raise

bulk_create_values async

bulk_create_values(rows: List[dict[str, Any]]) -> int

Insert many rows in a single INSERT ... VALUES (...), (...) statement.

Unlike :meth:add_all, this bypasses the unit-of-work — the rows are not refreshed nor attached to the session. Use when you have a large batch (≥ 50 rows) and don't need the ORM instances back; the round-trip count drops from N to 1.

Parameters:

Name Type Description Default
rows list[dict[str, Any]]

One mapping per row, keyed by column name (not attribute name; usually they match for SDK models).

required

Returns:

Name Type Description
int int

Number of rows inserted (len(rows) on success).

Raises:

Type Description
ConflictException

On unique / FK violations.

ValueError

When rows is empty.

Source code in tempest_fastapi_sdk/db/repository.py
async def bulk_create_values(
    self,
    rows: List[dict[str, Any]],
) -> int:
    """Insert many rows in a single ``INSERT ... VALUES (...), (...)`` statement.

    Unlike :meth:`add_all`, this bypasses the unit-of-work — the
    rows are not refreshed nor attached to the session. Use when
    you have a large batch (≥ 50 rows) and don't need the ORM
    instances back; the round-trip count drops from ``N`` to ``1``.

    Args:
        rows (list[dict[str, Any]]): One mapping per row,
            keyed by column name (not attribute name; usually
            they match for SDK models).

    Returns:
        int: Number of rows inserted (``len(rows)`` on success).

    Raises:
        ConflictException: On unique / FK violations.
        ValueError: When ``rows`` is empty.
    """
    if not rows:
        raise ValueError("bulk_create_values requires at least one row.")
    try:
        query = insert(self.model).values(rows)
        result = cast(CursorResult[Any], await self.session.execute(query))
        await self.session.commit()
        return result.rowcount or len(rows)
    except IntegrityError as exc:
        await self.session.rollback()
        logger.warning(
            "IntegrityError on %s.bulk_create_values: %s",
            self.model.__name__,
            exc.orig,
        )
        raise ConflictException(
            message=self._bulk_create_conflict_message,
        ) from exc
    except Exception:
        await self.session.rollback()
        raise

bulk_upsert async

bulk_upsert(
    rows: List[dict[str, Any]],
    *,
    conflict_columns: List[str],
    update_columns: List[str] | None = None,
) -> int

Issue an INSERT ... ON CONFLICT DO UPDATE in one round-trip.

Picks the dialect-specific upsert syntax automatically — Postgres (postgresql.insert) and SQLite (sqlite.insert) are supported. Other dialects raise :class:NotImplementedError so the caller can fall back to a transactional SELECT FOR UPDATE loop.

Parameters:

Name Type Description Default
rows list[dict[str, Any]]

One mapping per row.

required
conflict_columns list[str]

The columns whose conflict triggers the ON CONFLICT clause — typically the natural-key columns (e.g. ["sku"]). Must be backed by a UNIQUE index.

required
update_columns list[str] | None

Columns to refresh on conflict. None updates every column except conflict_columns and the primary key.

None

Returns:

Name Type Description
int int

Total rows touched (inserted + updated).

Raises:

Type Description
ConflictException

On non-recoverable integrity errors.

NotImplementedError

When the active SQLAlchemy dialect has no native upsert.

ValueError

When rows is empty.

Source code in tempest_fastapi_sdk/db/repository.py
async def bulk_upsert(
    self,
    rows: List[dict[str, Any]],
    *,
    conflict_columns: List[str],
    update_columns: List[str] | None = None,
) -> int:
    """Issue an ``INSERT ... ON CONFLICT DO UPDATE`` in one round-trip.

    Picks the dialect-specific upsert syntax automatically —
    Postgres (``postgresql.insert``) and SQLite
    (``sqlite.insert``) are supported. Other dialects raise
    :class:`NotImplementedError` so the caller can fall back to
    a transactional ``SELECT FOR UPDATE`` loop.

    Args:
        rows (list[dict[str, Any]]): One mapping per row.
        conflict_columns (list[str]): The columns whose
            conflict triggers the ``ON CONFLICT`` clause —
            typically the natural-key columns (e.g.
            ``["sku"]``). Must be backed by a UNIQUE index.
        update_columns (list[str] | None): Columns to refresh
            on conflict. ``None`` updates every column except
            ``conflict_columns`` and the primary key.

    Returns:
        int: Total rows touched (inserted + updated).

    Raises:
        ConflictException: On non-recoverable integrity errors.
        NotImplementedError: When the active SQLAlchemy dialect
            has no native upsert.
        ValueError: When ``rows`` is empty.
    """
    if not rows:
        raise ValueError("bulk_upsert requires at least one row.")

    bind = self.session.get_bind()
    dialect_name = bind.dialect.name
    stmt: Any
    if dialect_name == "postgresql":
        from sqlalchemy.dialects.postgresql import insert as _pg_insert

        stmt = _pg_insert(self.model).values(rows)
    elif dialect_name == "sqlite":
        from sqlalchemy.dialects.sqlite import insert as _sqlite_insert

        stmt = _sqlite_insert(self.model).values(rows)
    else:
        raise NotImplementedError(
            f"bulk_upsert: dialect {dialect_name!r} not supported. "
            f"Drop to a SELECT FOR UPDATE + UPDATE loop or open an "
            f"issue at https://github.com/mauriciobenjamin700/"
            f"tempest-fastapi-sdk/issues."
        )

    if update_columns is None:
        pk_columns = {col.name for col in self.model.__table__.primary_key}
        skip = set(conflict_columns) | pk_columns
        update_columns = [
            col.name for col in self.model.__table__.columns if col.name not in skip
        ]
    update_set = {col: getattr(stmt.excluded, col) for col in update_columns}
    stmt = stmt.on_conflict_do_update(
        index_elements=conflict_columns,
        set_=update_set,
    )

    try:
        result = cast(CursorResult[Any], await self.session.execute(stmt))
        await self.session.commit()
        return result.rowcount or len(rows)
    except IntegrityError as exc:
        await self.session.rollback()
        logger.warning(
            "IntegrityError on %s.bulk_upsert: %s",
            self.model.__name__,
            exc.orig,
        )
        raise ConflictException(
            message=self._bulk_create_conflict_message,
        ) from exc
    except Exception:
        await self.session.rollback()
        raise

delete async

delete(id: UUID) -> None

Delete a single row by its primary key.

Parameters:

Name Type Description Default
id UUID

The primary key.

required

Raises:

Type Description
AppException

self.not_found_exception if no record with id exists.

Source code in tempest_fastapi_sdk/db/repository.py
async def delete(self, id: UUID) -> None:
    """Delete a single row by its primary key.

    Args:
        id (UUID): The primary key.

    Raises:
        AppException: ``self.not_found_exception`` if no record
            with ``id`` exists.
    """
    try:
        query = delete(self.model).where(self.model.id == id)
        result = cast(CursorResult[Any], await self.session.execute(query))
        if result.rowcount == 0:
            self._raise_not_found()
        await self.session.commit()
    except AppException:
        raise
    except Exception:
        await self.session.rollback()
        raise

delete_many async

delete_many(filters: dict[str, Any]) -> int

Delete every row matching filters.

An empty filters dict deletes every row in the table. Callers must opt in explicitly — the behavior is intentional.

Parameters:

Name Type Description Default
filters dict[str, Any]

The conditions identifying the rows to delete.

required

Returns:

Name Type Description
int int

The number of rows deleted.

Source code in tempest_fastapi_sdk/db/repository.py
async def delete_many(self, filters: dict[str, Any]) -> int:
    """Delete every row matching ``filters``.

    An empty ``filters`` dict deletes every row in the table.
    Callers must opt in explicitly — the behavior is intentional.

    Args:
        filters (dict[str, Any]): The conditions identifying the
            rows to delete.

    Returns:
        int: The number of rows deleted.
    """
    try:
        query = delete(self.model)
        if filters:
            query = self._apply_filters(query, filters)
        result = cast(CursorResult[Any], await self.session.execute(query))
        await self.session.commit()
        return result.rowcount or 0
    except Exception:
        await self.session.rollback()
        raise

delete_batch async

delete_batch(ids: List[UUID]) -> int

Delete several rows by primary key.

Parameters:

Name Type Description Default
ids list[UUID]

The primary keys to delete.

required

Returns:

Name Type Description
int int

The number of rows deleted.

Source code in tempest_fastapi_sdk/db/repository.py
async def delete_batch(self, ids: List[UUID]) -> int:
    """Delete several rows by primary key.

    Args:
        ids (list[UUID]): The primary keys to delete.

    Returns:
        int: The number of rows deleted.
    """
    try:
        query = delete(self.model).where(self.model.id.in_(ids))
        result = cast(CursorResult[Any], await self.session.execute(query))
        await self.session.commit()
        return result.rowcount or 0
    except Exception:
        await self.session.rollback()
        raise

soft_delete async

soft_delete(id: UUID) -> ModelType

Soft-delete a row by setting is_active=False.

Loads the row, flips is_active, persists. Returns the refreshed instance so callers can inspect the post-state.

Parameters:

Name Type Description Default
id UUID

The primary key.

required

Returns:

Name Type Description
ModelType ModelType

The row with is_active=False.

Raises:

Type Description
AppException

self.not_found_exception if no record with id exists.

Source code in tempest_fastapi_sdk/db/repository.py
async def soft_delete(self, id: UUID) -> ModelType:
    """Soft-delete a row by setting ``is_active=False``.

    Loads the row, flips ``is_active``, persists. Returns the
    refreshed instance so callers can inspect the post-state.

    Args:
        id (UUID): The primary key.

    Returns:
        ModelType: The row with ``is_active=False``.

    Raises:
        AppException: ``self.not_found_exception`` if no record
            with ``id`` exists.
    """
    instance = await self.get_by_id(id)
    instance.is_active = False
    return await self.update(instance)

restore async

restore(id: UUID) -> ModelType

Reactivate a soft-deleted row by setting is_active=True.

Parameters:

Name Type Description Default
id UUID

The primary key.

required

Returns:

Name Type Description
ModelType ModelType

The row with is_active=True.

Raises:

Type Description
AppException

self.not_found_exception if no record with id exists.

Source code in tempest_fastapi_sdk/db/repository.py
async def restore(self, id: UUID) -> ModelType:
    """Reactivate a soft-deleted row by setting ``is_active=True``.

    Args:
        id (UUID): The primary key.

    Returns:
        ModelType: The row with ``is_active=True``.

    Raises:
        AppException: ``self.not_found_exception`` if no record
            with ``id`` exists.
    """
    instance = await self.get_by_id(id)
    instance.is_active = True
    return await self.update(instance)

count async

count(filters: dict[str, Any] | None = None) -> int

Count the rows matching filters.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

The filter conditions.

None

Returns:

Name Type Description
int int

The matching row count.

Source code in tempest_fastapi_sdk/db/repository.py
async def count(self, filters: dict[str, Any] | None = None) -> int:
    """Count the rows matching ``filters``.

    Args:
        filters (dict[str, Any] | None): The filter conditions.

    Returns:
        int: The matching row count.
    """
    query = select(func.count()).select_from(self.model)
    if filters:
        query = self._apply_filters(query, filters)
    result = await self.session.execute(query)
    return result.scalar() or 0

map_to_schema

map_to_schema(instance: ModelType) -> Any

Map an ORM row to its schema/domain representation.

Concrete repositories MUST implement this to bridge the data layer and the rest of the application.

Parameters:

Name Type Description Default
instance ModelType

The ORM row to convert.

required

Returns:

Name Type Description
Any Any

The schema/domain object.

Raises:

Type Description
NotImplementedError

Always — subclasses must override.

Source code in tempest_fastapi_sdk/db/repository.py
def map_to_schema(self, instance: ModelType) -> Any:
    """Map an ORM row to its schema/domain representation.

    Concrete repositories MUST implement this to bridge the data
    layer and the rest of the application.

    Args:
        instance (ModelType): The ORM row to convert.

    Returns:
        Any: The schema/domain object.

    Raises:
        NotImplementedError: Always — subclasses must override.
    """
    raise NotImplementedError(
        "Subclasses must implement map_to_schema",
    )

map_to_model

map_to_model(data: dict[str, Any]) -> ModelType

Build an ORM instance from a plain dict payload.

Default implementation constructs self.model(**data); override for custom field mapping.

Parameters:

Name Type Description Default
data dict[str, Any]

The payload.

required

Returns:

Name Type Description
ModelType ModelType

A new (unpersisted) ORM instance.

Source code in tempest_fastapi_sdk/db/repository.py
def map_to_model(self, data: dict[str, Any]) -> ModelType:
    """Build an ORM instance from a plain ``dict`` payload.

    Default implementation constructs ``self.model(**data)``;
    override for custom field mapping.

    Args:
        data (dict[str, Any]): The payload.

    Returns:
        ModelType: A new (unpersisted) ORM instance.
    """
    return self.model(**data)

map_to_response

map_to_response(instance: ModelType) -> Any

Map an ORM row to its API response schema.

Concrete repositories MUST implement this when used from the router layer.

Parameters:

Name Type Description Default
instance ModelType

The ORM row to convert.

required

Returns:

Name Type Description
Any Any

The response schema.

Raises:

Type Description
NotImplementedError

Always — subclasses must override.

Source code in tempest_fastapi_sdk/db/repository.py
def map_to_response(self, instance: ModelType) -> Any:
    """Map an ORM row to its API response schema.

    Concrete repositories MUST implement this when used from the
    router layer.

    Args:
        instance (ModelType): The ORM row to convert.

    Returns:
        Any: The response schema.

    Raises:
        NotImplementedError: Always — subclasses must override.
    """
    raise NotImplementedError(
        "Subclasses must implement map_to_response",
    )

SoftDeleteMixin

Add a deleted_at timestamp for non-destructive deletes.

Pairs with the canonical is_active flag on :class:tempest_fastapi_sdk.BaseModel: is_active toggles visibility quickly while deleted_at records when the soft delete happened (useful for audit and retention policies).

A row is considered "alive" when deleted_at IS NULL. Filtering is the caller's responsibility — the mixin keeps the column declarative-only so it composes with arbitrary query strategies (global filters, partial indexes, repository hooks).

Attributes:

Name Type Description
deleted_at datetime | None

Timestamp of the soft delete, or None while the row is alive.

is_deleted property

is_deleted: bool

Whether the row is currently soft-deleted.

Returns:

Name Type Description
bool bool

True when deleted_at is non-null.

mark_deleted

mark_deleted() -> None

Stamp deleted_at with the current UTC instant.

Source code in tempest_fastapi_sdk/db/mixins.py
def mark_deleted(self) -> None:
    """Stamp ``deleted_at`` with the current UTC instant."""
    self.deleted_at = utcnow()

mark_restored

mark_restored() -> None

Clear deleted_at to mark the row alive again.

Source code in tempest_fastapi_sdk/db/mixins.py
def mark_restored(self) -> None:
    """Clear ``deleted_at`` to mark the row alive again."""
    self.deleted_at = None

AuditMixin

Add created_by / updated_by foreign-key columns.

Tracks which user (by UUID) last touched a row. The mixin only declares the columns — populating them is the application's responsibility, typically inside the service layer (where the current user is in scope) right before calling the repository.

Attributes:

Name Type Description
created_by UUID | None

UUID of the user that created the row. Nullable for system-generated rows.

updated_by UUID | None

UUID of the user that last updated the row. Nullable until the first update.

stamp_created_by

stamp_created_by(user_id: UUID) -> None

Set both audit columns to user_id on initial insert.

Parameters:

Name Type Description Default
user_id UUID

The acting user's primary key.

required
Source code in tempest_fastapi_sdk/db/mixins.py
def stamp_created_by(self, user_id: UUID) -> None:
    """Set both audit columns to ``user_id`` on initial insert.

    Args:
        user_id (UUID): The acting user's primary key.
    """
    self.created_by = user_id
    self.updated_by = user_id

stamp_updated_by

stamp_updated_by(user_id: UUID) -> None

Update updated_by to user_id ahead of an UPDATE.

Parameters:

Name Type Description Default
user_id UUID

The acting user's primary key.

required
Source code in tempest_fastapi_sdk/db/mixins.py
def stamp_updated_by(self, user_id: UUID) -> None:
    """Update ``updated_by`` to ``user_id`` ahead of an UPDATE.

    Args:
        user_id (UUID): The acting user's primary key.
    """
    self.updated_by = user_id

AsyncDatabaseManager

AsyncDatabaseManager(
    db_url: str,
    *,
    echo: bool = False,
    pool_size: int = 10,
    max_overflow: int = 20,
    pool_recycle: int = 3600,
    connect_args: dict[str, Any] | None = None,
    poolclass: type[Pool] | None = None,
    **engine_kwargs: Any,
)

Manage the async SQLAlchemy engine and session lifecycle.

Handles engine creation tailored to the database backend (SQLite gets check_same_thread=False by default, everything else gets a pooled config), session factory construction, and table create/drop helpers. Designed to be instantiated once per application and reused across requests.

Backend detection uses sqlalchemy.engine.make_url so URLs like sqlite+aiosqlite://... are matched precisely without relying on substring tricks.

Attributes:

Name Type Description
is_sqlite bool

Whether the URL targets a SQLite backend.

The connection URL itself is stored on a private attribute so it never leaks through repr() or accidental logging. Use the :attr:db_url_safe property when a redacted form is needed.

Initialize the manager (does not open connections yet).

Parameters:

Name Type Description Default
db_url str

The database connection URL.

required
echo bool

Whether to emit SQL to stdout.

False
pool_size int

Number of permanent connections in the pool. Ignored for SQLite URLs.

10
max_overflow int

Extra connections allowed above the pool size. Ignored for SQLite URLs.

20
pool_recycle int

Recycle connections older than this many seconds. Ignored for SQLite URLs.

3600
connect_args dict[str, Any] | None

Driver-level arguments forwarded to create_async_engine (e.g. {"ssl": "require"} for asyncpg). SQLite always receives check_same_thread=False unless explicitly overridden here.

None
poolclass type[Pool] | None

Override SQLAlchemy's default pool class. Useful for tests (poolclass=NullPool) or specialized topologies.

None
**engine_kwargs Any

Any additional keyword arguments are passed through to create_async_engine verbatim.

{}
Source code in tempest_fastapi_sdk/db/connection.py
def __init__(
    self,
    db_url: str,
    *,
    echo: bool = False,
    pool_size: int = 10,
    max_overflow: int = 20,
    pool_recycle: int = 3600,
    connect_args: dict[str, Any] | None = None,
    poolclass: type[Pool] | None = None,
    **engine_kwargs: Any,
) -> None:
    """Initialize the manager (does not open connections yet).

    Args:
        db_url (str): The database connection URL.
        echo (bool): Whether to emit SQL to stdout.
        pool_size (int): Number of permanent connections in the
            pool. Ignored for SQLite URLs.
        max_overflow (int): Extra connections allowed above the
            pool size. Ignored for SQLite URLs.
        pool_recycle (int): Recycle connections older than this
            many seconds. Ignored for SQLite URLs.
        connect_args (dict[str, Any] | None): Driver-level
            arguments forwarded to ``create_async_engine``
            (e.g. ``{"ssl": "require"}`` for asyncpg). SQLite
            always receives ``check_same_thread=False`` unless
            explicitly overridden here.
        poolclass (type[Pool] | None): Override SQLAlchemy's
            default pool class. Useful for tests
            (``poolclass=NullPool``) or specialized topologies.
        **engine_kwargs: Any additional keyword arguments are
            passed through to ``create_async_engine`` verbatim.
    """
    self._db_url: str = db_url
    self.is_sqlite: bool = make_url(db_url).get_backend_name() == "sqlite"
    self._echo: bool = echo
    self._pool_size: int = pool_size
    self._max_overflow: int = max_overflow
    self._pool_recycle: int = pool_recycle
    self._connect_args: dict[str, Any] = dict(connect_args or {})
    self._poolclass: type[Pool] | None = poolclass
    self._engine_kwargs: dict[str, Any] = engine_kwargs
    self._engine: AsyncEngine | None = None
    self._session_maker: async_sessionmaker[AsyncSession] | None = None

db_url_safe property

db_url_safe: str

Return the URL with credentials masked.

Useful for diagnostics, health payloads or log lines — postgresql+asyncpg://user:pass@host/db becomes postgresql+asyncpg://***@host/db.

Returns:

Name Type Description
str str

The URL safe to surface outside the manager.

is_connected property

is_connected: bool

Whether the engine is currently initialized.

Returns:

Name Type Description
bool bool

True if :meth:connect has been called and

bool

meth:disconnect has not.

connect async

connect() -> None

Create the engine and session factory if they don't exist.

Idempotent — calling twice is a no-op.

Source code in tempest_fastapi_sdk/db/connection.py
async def connect(self) -> None:
    """Create the engine and session factory if they don't exist.

    Idempotent — calling twice is a no-op.
    """
    if self._engine is not None:
        return

    kwargs: dict[str, Any] = {"echo": self._echo, **self._engine_kwargs}
    connect_args = dict(self._connect_args)

    if self.is_sqlite:
        connect_args.setdefault("check_same_thread", False)
    else:
        kwargs.setdefault("pool_pre_ping", True)
        kwargs.setdefault("pool_recycle", self._pool_recycle)
        kwargs.setdefault("pool_size", self._pool_size)
        kwargs.setdefault("max_overflow", self._max_overflow)

    if connect_args:
        kwargs["connect_args"] = connect_args
    if self._poolclass is not None:
        kwargs["poolclass"] = self._poolclass

    self._engine = create_async_engine(self._db_url, **kwargs)
    self._session_maker = async_sessionmaker(
        self._engine,
        expire_on_commit=False,
        class_=AsyncSession,
    )

disconnect async

disconnect() -> None

Dispose the engine and clear the session factory.

Safe to call multiple times.

Source code in tempest_fastapi_sdk/db/connection.py
async def disconnect(self) -> None:
    """Dispose the engine and clear the session factory.

    Safe to call multiple times.
    """
    if self._engine is not None:
        await self._engine.dispose()
        self._engine = None
        self._session_maker = None

get_session async

get_session() -> AsyncSession

Return a new AsyncSession bound to the engine.

Lazy-connects on first use. The caller is responsible for closing the session (use :meth:get_session_context for managed lifecycle).

Returns:

Name Type Description
AsyncSession AsyncSession

A new session.

Source code in tempest_fastapi_sdk/db/connection.py
async def get_session(self) -> AsyncSession:
    """Return a new ``AsyncSession`` bound to the engine.

    Lazy-connects on first use. The caller is responsible for
    closing the session (use :meth:`get_session_context` for
    managed lifecycle).

    Returns:
        AsyncSession: A new session.
    """
    if self._engine is None:
        await self.connect()
    return self._require_session_maker()()

get_session_context async

get_session_context() -> AsyncGenerator[AsyncSession]

Yield a session that auto-commits on exit and rolls back on error.

Yields:

Name Type Description
AsyncSession AsyncGenerator[AsyncSession]

A managed session.

Raises:

Type Description
Exception

Re-raises whatever the caller raised inside the async with block, after rolling back.

Source code in tempest_fastapi_sdk/db/connection.py
@asynccontextmanager
async def get_session_context(self) -> AsyncGenerator[AsyncSession]:
    """Yield a session that auto-commits on exit and rolls back on error.

    Yields:
        AsyncSession: A managed session.

    Raises:
        Exception: Re-raises whatever the caller raised inside
            the ``async with`` block, after rolling back.
    """
    if self._engine is None:
        await self.connect()
    session = self._require_session_maker()()
    try:
        yield session
        await session.commit()
    except Exception:
        await session.rollback()
        raise
    finally:
        await session.close()

session_dependency async

session_dependency() -> AsyncGenerator[AsyncSession]

FastAPI dependency yielding one session per request.

Use as Depends(db.session_dependency). Differs from :meth:get_session_context in that it does not commit on success — commits are the responsibility of the service / repository layer. The session is closed when the request scope ends; failures bubble up unchanged.

Yields:

Name Type Description
AsyncSession AsyncGenerator[AsyncSession]

A request-scoped session.

Source code in tempest_fastapi_sdk/db/connection.py
async def session_dependency(self) -> AsyncGenerator[AsyncSession]:
    """FastAPI dependency yielding one session per request.

    Use as ``Depends(db.session_dependency)``. Differs from
    :meth:`get_session_context` in that it does **not** commit on
    success — commits are the responsibility of the service /
    repository layer. The session is closed when the request
    scope ends; failures bubble up unchanged.

    Yields:
        AsyncSession: A request-scoped session.
    """
    if self._engine is None:
        await self.connect()
    session = self._require_session_maker()()
    try:
        yield session
    finally:
        await session.close()

health_check async

health_check() -> bool

Return whether a trivial SELECT 1 succeeds.

Suitable for /health endpoints. Swallows every exception and returns False so callers can branch on the result without dealing with driver-specific error types.

Returns:

Name Type Description
bool bool

True when the database responded with 1,

bool

False on any failure.

Source code in tempest_fastapi_sdk/db/connection.py
async def health_check(self) -> bool:
    """Return whether a trivial ``SELECT 1`` succeeds.

    Suitable for ``/health`` endpoints. Swallows every exception
    and returns ``False`` so callers can branch on the result
    without dealing with driver-specific error types.

    Returns:
        bool: ``True`` when the database responded with ``1``,
        ``False`` on any failure.
    """
    try:
        if self._engine is None:
            await self.connect()
        async with self._require_session_maker()() as session:
            result = await session.execute(text("SELECT 1"))
            return result.scalar() == 1
    except Exception:
        return False

create_tables async

create_tables() -> None

Issue CREATE TABLE for every model registered on BaseModel.

Intended for tests and local development. Production schemas should be managed by Alembic (see :class:tempest_fastapi_sdk.db.migrations.AlembicHelper).

Source code in tempest_fastapi_sdk/db/connection.py
async def create_tables(self) -> None:
    """Issue ``CREATE TABLE`` for every model registered on ``BaseModel``.

    Intended for tests and local development. Production schemas
    should be managed by Alembic (see
    :class:`tempest_fastapi_sdk.db.migrations.AlembicHelper`).
    """
    if self._engine is None:
        await self.connect()
    if self._engine is None:
        raise RuntimeError("Engine is not connected.")
    async with self._engine.begin() as conn:
        await conn.run_sync(BaseModel.metadata.create_all)

drop_tables async

drop_tables() -> None

Issue DROP TABLE for every model registered on BaseModel.

Intended for tests and local development.

Source code in tempest_fastapi_sdk/db/connection.py
async def drop_tables(self) -> None:
    """Issue ``DROP TABLE`` for every model registered on ``BaseModel``.

    Intended for tests and local development.
    """
    if self._engine is None:
        await self.connect()
    if self._engine is None:
        raise RuntimeError("Engine is not connected.")
    async with self._engine.begin() as conn:
        await conn.run_sync(BaseModel.metadata.drop_all)

AlembicHelper

AlembicHelper(config_path: str = 'alembic.ini', *, db_url: str | None = None)

High-level wrapper around the Alembic command surface.

Encapsulates a single alembic.ini configuration and exposes the operations that matter for day-to-day work — upgrade, downgrade, revision authoring, schema-vs-models check — without leaking Alembic internals into application code.

All methods are synchronous because Alembic itself is sync; run them from CLI scripts or from FastAPI's startup hook via asyncio.to_thread if you must call them from async code.

Attributes:

Name Type Description
config_path str

Path to the alembic.ini configuration.

Initialize the helper.

Parameters:

Name Type Description Default
config_path str

Path to alembic.ini. Resolved relative to the current working directory.

'alembic.ini'
db_url str | None

If provided, overrides sqlalchemy.url from the .ini file. Useful when the URL must come from settings/environment rather than the ini.

None
Source code in tempest_fastapi_sdk/db/migrations.py
def __init__(
    self,
    config_path: str = "alembic.ini",
    *,
    db_url: str | None = None,
) -> None:
    """Initialize the helper.

    Args:
        config_path (str): Path to ``alembic.ini``. Resolved
            relative to the current working directory.
        db_url (str | None): If provided, overrides
            ``sqlalchemy.url`` from the ``.ini`` file. Useful
            when the URL must come from settings/environment
            rather than the ini.
    """
    self.config_path: str = config_path
    self._db_url_override: str | None = db_url

config property

config: Config

Return a fresh :class:alembic.config.Config instance.

A new instance is built on every access so the helper stays stateless and safe to share across threads — Alembic mutates the config object during command execution.

sqlalchemy.url resolution order:

  1. db_url passed on the constructor (explicit override).
  2. The value already on the ini file.
  3. The DATABASE_URL environment variable (loaded from .env before invoking the helper).
  4. src.core.settings.settings.DATABASE_URL when the scaffolded layout is detected.

The SDK-generated ini ships with sqlalchemy.url = empty on purpose so secrets never enter version control — the resolution chain above fills it at runtime.

Returns:

Name Type Description
Config Config

The configured Alembic config.

init

init(
    directory: str = "alembic",
    *,
    metadata_module: str | None = None,
    metadata_attr: str = "BaseModel",
    db_url: str = "sqlite+aiosqlite:///./app.db",
) -> None

Scaffold a new Alembic environment in directory.

Wraps alembic init -t async and then overwrites the generated env.py with the SDK's template, which already wires the metadata import, sets compare_type / compare_server_default and enables batch mode for SQLite.

Parameters:

Name Type Description Default
directory str

Target directory for versions/ and env.py. Created if missing.

'alembic'
metadata_module str | None

Dotted module path that exposes the SQLAlchemy metadata (e.g. "app.db"). When None, the env.py is left with target_metadata = None so the user can wire it manually.

None
metadata_attr str

Name of the attribute inside metadata_module whose .metadata is used as the autogenerate target. Defaults to "BaseModel".

'BaseModel'
db_url str

Value to write under sqlalchemy.url in the generated alembic.ini. Replace later via env-var injection or by passing db_url to the constructor.

'sqlite+aiosqlite:///./app.db'
Source code in tempest_fastapi_sdk/db/migrations.py
def init(
    self,
    directory: str = "alembic",
    *,
    metadata_module: str | None = None,
    metadata_attr: str = "BaseModel",
    db_url: str = "sqlite+aiosqlite:///./app.db",
) -> None:
    """Scaffold a new Alembic environment in ``directory``.

    Wraps ``alembic init -t async`` and then overwrites the
    generated ``env.py`` with the SDK's template, which already
    wires the metadata import, sets ``compare_type`` /
    ``compare_server_default`` and enables batch mode for SQLite.

    Args:
        directory (str): Target directory for ``versions/`` and
            ``env.py``. Created if missing.
        metadata_module (str | None): Dotted module path that
            exposes the SQLAlchemy metadata (e.g. ``"app.db"``).
            When ``None``, the env.py is left with
            ``target_metadata = None`` so the user can wire it
            manually.
        metadata_attr (str): Name of the attribute inside
            ``metadata_module`` whose ``.metadata`` is used as
            the autogenerate target. Defaults to ``"BaseModel"``.
        db_url (str): Value to write under ``sqlalchemy.url`` in
            the generated ``alembic.ini``. Replace later via
            env-var injection or by passing ``db_url`` to the
            constructor.
    """
    # Alembic's ``command.init`` writes the ini at
    # ``config.config_file_name``; pre-seed it with our target
    # path so the file lands where the helper expects.
    ini_path = Path(self.config_path)
    ini_path.parent.mkdir(parents=True, exist_ok=True)
    config = Config(str(ini_path))
    config.set_main_option("script_location", directory)
    config.set_main_option("sqlalchemy.url", db_url)
    command.init(config, directory, template="async")

    # Patch the generated env.py with the SDK template so
    # autogenerate gets the project's metadata + sensible
    # comparison flags out of the box.
    env_py = Path(directory) / "env.py"
    template_text = (
        resources.files("tempest_fastapi_sdk.db._alembic_templates")
        .joinpath("env.py.template")
        .read_text(encoding="utf-8")
    )

    if metadata_module is None:
        metadata_import = "target_metadata = None"
    else:
        metadata_import = (
            f"from {metadata_module} import {metadata_attr}\n"
            f"target_metadata = {metadata_attr}.metadata"
        )
    env_py.write_text(
        template_text.replace("__METADATA_IMPORT__", metadata_import),
        encoding="utf-8",
    )

    # Overwrite the ini Alembic just wrote with the SDK's
    # opinionated layout (logger sections, file_template, UTC).
    # ``sqlalchemy.url`` is intentionally left empty so the
    # credentials never land in version control. The companion
    # ``env.py`` template resolves the URL at runtime from
    # ``DATABASE_URL`` env var (or ``src.core.settings``) and
    # injects it back into the Alembic config before the engine
    # is built. Pass ``db_url=...`` on the constructor to override
    # for one-off operations (CI smoke, scripted migrations).
    ini_lines = [
        "[alembic]",
        f"script_location = {directory}",
        "sqlalchemy.url = ",
        (
            "file_template = "
            "%%(year)d_%%(month).2d_%%(day).2d_"
            "%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s"
        ),
        "timezone = UTC",
        "",
        # Auto-format every freshly generated revision so the
        # files autogenerate emits (long ``sa.Column`` lines,
        # trailing whitespace in the docstring header when
        # ``down_revision`` is ``None``) are lint-clean out of
        # the box.
        #
        # Order matters: ``ruff format`` MUST run first — it
        # wraps over-length lines (E501) and strips trailing
        # whitespace (W291). Running ``ruff check --fix`` first
        # would emit noisy "found N errors / N fixed / M
        # remaining" output for things the formatter is about
        # to fix on the next hook. ``--quiet`` silences the
        # second pass when nothing actionable is left.
        "[post_write_hooks]",
        "hooks = ruff_format, ruff_fix",
        "ruff_format.type = exec",
        "ruff_format.executable = ruff",
        "ruff_format.options = format --quiet REVISION_SCRIPT_FILENAME",
        "ruff_fix.type = exec",
        "ruff_fix.executable = ruff",
        "ruff_fix.options = check --fix --quiet REVISION_SCRIPT_FILENAME",
        "",
        # No [loggers]/[handlers]/[formatters] sections on purpose.
        # ``env.py`` runs inside the host application process, and
        # the host already configured Python's logging tree via
        # ``configure_logging``. If we shipped the stock alembic.ini
        # ``[logger_root] level = WARN handlers = console`` block,
        # ``fileConfig(alembic.ini)`` from env.py would reset the
        # root logger to WARN + a stderr handler, silencing the SDK
        # 500 handler, the /logs writer and every JSON record the
        # app emits. The companion env.py template still calls
        # ``fileConfig`` (guarded on the section presence) so users
        # who manually re-add a ``[loggers]`` block keep working.
    ]
    ini_path.write_text("\n".join(ini_lines), encoding="utf-8")

upgrade

upgrade(revision: str = 'head') -> None

Apply migrations up to revision (default: head).

Parameters:

Name Type Description Default
revision str

Target revision identifier or relative spec ("+1"). "head" runs every pending migration.

'head'
Source code in tempest_fastapi_sdk/db/migrations.py
def upgrade(self, revision: str = "head") -> None:
    """Apply migrations up to ``revision`` (default: ``head``).

    Args:
        revision (str): Target revision identifier or relative
            spec (``"+1"``). ``"head"`` runs every pending
            migration.
    """
    command.upgrade(self.config, revision)

downgrade

downgrade(revision: str = '-1') -> None

Revert migrations down to revision (default: one step back).

Parameters:

Name Type Description Default
revision str

Target revision identifier or relative spec. "base" rolls everything back.

'-1'
Source code in tempest_fastapi_sdk/db/migrations.py
def downgrade(self, revision: str = "-1") -> None:
    """Revert migrations down to ``revision`` (default: one step back).

    Args:
        revision (str): Target revision identifier or relative
            spec. ``"base"`` rolls everything back.
    """
    command.downgrade(self.config, revision)

current

current() -> str | None

Return the revision the database is currently stamped at.

Reads alembic_version via a temporary sync engine derived from the configured URL (async drivers get stripped).

Returns:

Type Description
str | None

str | None: The revision identifier, or None when the

str | None

alembic_version table is missing/empty.

Source code in tempest_fastapi_sdk/db/migrations.py
def current(self) -> str | None:
    """Return the revision the database is currently stamped at.

    Reads ``alembic_version`` via a temporary sync engine derived
    from the configured URL (async drivers get stripped).

    Returns:
        str | None: The revision identifier, or ``None`` when the
        ``alembic_version`` table is missing/empty.
    """
    config = self.config
    url = config.get_main_option("sqlalchemy.url")
    if url is None:
        raise RuntimeError("sqlalchemy.url is not configured in alembic.ini")
    engine = create_engine(_strip_async_driver(url))
    try:
        with engine.connect() as connection:
            migration_context = MigrationContext.configure(connection)
            return migration_context.get_current_revision()
    finally:
        engine.dispose()

heads

heads() -> list[str]

Return every head revision known to the script directory.

Multiple heads indicate divergent branches in the migration graph — usually a sign that a merge migration is needed.

Returns:

Type Description
list[str]

list[str]: The head revision identifiers.

Source code in tempest_fastapi_sdk/db/migrations.py
def heads(self) -> list[str]:
    """Return every head revision known to the script directory.

    Multiple heads indicate divergent branches in the migration
    graph — usually a sign that a merge migration is needed.

    Returns:
        list[str]: The head revision identifiers.
    """
    script = ScriptDirectory.from_config(self.config)
    return list(script.get_heads())

history

history(*, verbose: bool = False) -> str

Return the migration history as a printable string.

Wraps alembic history and captures stdout so the result can be logged or returned from an admin endpoint.

Parameters:

Name Type Description Default
verbose bool

Forward --verbose to Alembic.

False

Returns:

Name Type Description
str str

The captured output.

Source code in tempest_fastapi_sdk/db/migrations.py
def history(self, *, verbose: bool = False) -> str:
    """Return the migration history as a printable string.

    Wraps ``alembic history`` and captures stdout so the result
    can be logged or returned from an admin endpoint.

    Args:
        verbose (bool): Forward ``--verbose`` to Alembic.

    Returns:
        str: The captured output.
    """
    buffer = StringIO()
    with redirect_stdout(buffer):
        command.history(self.config, verbose=verbose)
    return buffer.getvalue()

revision

revision(
    message: str, *, autogenerate: bool = True, sql: bool = False, head: str = "head"
) -> Any

Create a new revision file.

Parameters:

Name Type Description Default
message str

Description of the change (becomes the migration slug).

required
autogenerate bool

Run autogenerate against the live schema. When False, an empty revision is created for the user to fill in.

True
sql bool

Emit SQL to stdout instead of executing.

False
head str

Parent revision; defaults to the current head.

'head'

Returns:

Name Type Description
Any Any

The Alembic Script (or list of scripts) created

Any

by the command, as returned by alembic.command.revision.

Source code in tempest_fastapi_sdk/db/migrations.py
def revision(
    self,
    message: str,
    *,
    autogenerate: bool = True,
    sql: bool = False,
    head: str = "head",
) -> Any:
    """Create a new revision file.

    Args:
        message (str): Description of the change (becomes the
            migration slug).
        autogenerate (bool): Run autogenerate against the live
            schema. When ``False``, an empty revision is created
            for the user to fill in.
        sql (bool): Emit SQL to stdout instead of executing.
        head (str): Parent revision; defaults to the current head.

    Returns:
        Any: The Alembic ``Script`` (or list of scripts) created
        by the command, as returned by ``alembic.command.revision``.
    """
    return command.revision(
        self.config,
        message=message,
        autogenerate=autogenerate,
        sql=sql,
        head=head,
    )

stamp

stamp(revision: str = 'head') -> None

Stamp the database with revision without running migrations.

Useful when importing an existing schema into Alembic for the first time.

Parameters:

Name Type Description Default
revision str

The revision to stamp.

'head'
Source code in tempest_fastapi_sdk/db/migrations.py
def stamp(self, revision: str = "head") -> None:
    """Stamp the database with ``revision`` without running migrations.

    Useful when importing an existing schema into Alembic for the
    first time.

    Args:
        revision (str): The revision to stamp.
    """
    command.stamp(self.config, revision)

check

check() -> bool

Return True if no autogenerate diff would be produced.

Wraps alembic check (added in Alembic 1.9). Suitable for CI to fail when models drift from the migration tree.

Returns:

Name Type Description
bool bool

True if the schema matches the models.

Source code in tempest_fastapi_sdk/db/migrations.py
def check(self) -> bool:
    """Return ``True`` if no autogenerate diff would be produced.

    Wraps ``alembic check`` (added in Alembic 1.9). Suitable for
    CI to fail when models drift from the migration tree.

    Returns:
        bool: ``True`` if the schema matches the models.
    """
    try:
        command.check(self.config)
        return True
    except Exception:
        return False

show

show(revision: str = 'head') -> str

Return the details of a single revision.

Parameters:

Name Type Description Default
revision str

The revision to inspect.

'head'

Returns:

Name Type Description
str str

Multi-line description (id, parent, doc, path).

Source code in tempest_fastapi_sdk/db/migrations.py
def show(self, revision: str = "head") -> str:
    """Return the details of a single revision.

    Args:
        revision (str): The revision to inspect.

    Returns:
        str: Multi-line description (id, parent, doc, path).
    """
    script = ScriptDirectory.from_config(self.config)
    rev = script.get_revision(revision)
    if rev is None:
        return ""  # type: ignore[unreachable]
    lines = [
        f"Rev: {rev.revision}",
        f"Parent: {rev.down_revision}",
        f"Path: {rev.path}",
        f"Doc: {rev.doc}",
    ]
    return "\n".join(lines)

Schemas

tempest_fastapi_sdk.schemas

BaseSchema

Bases: BaseModel

Base class for every Pydantic schema in an application.

Centralizes the configuration that all DTOs share: ignore extra fields, allow building schemas from ORM attributes, serialize enum values, strip whitespace from strings, and validate assignments after construction.

Attributes:

Name Type Description
model_config ConfigDict

The Pydantic configuration.

to_dict

to_dict(
    exclude: list[str] | None = None, include: dict[str, Any] | None = None
) -> dict[str, Any]

Serialize the schema to a plain dict.

Drops None values, removes keys listed in exclude and merges include on top of the remaining payload.

Parameters:

Name Type Description Default
exclude list[str] | None

Field names to drop from the output dictionary.

None
include dict[str, Any] | None

Extra entries to merge into the output (override existing keys).

None

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The serialized representation.

Source code in tempest_fastapi_sdk/schemas/base.py
def to_dict(
    self,
    exclude: list[str] | None = None,
    include: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Serialize the schema to a plain ``dict``.

    Drops ``None`` values, removes keys listed in ``exclude``
    and merges ``include`` on top of the remaining payload.

    Args:
        exclude (list[str] | None): Field names to drop from the
            output dictionary.
        include (dict[str, Any] | None): Extra entries to merge
            into the output (override existing keys).

    Returns:
        dict[str, Any]: The serialized representation.
    """
    data = self.model_dump(exclude_none=True, exclude_unset=True)
    return modify_dict(data, exclude=exclude, include=include)

to_json

to_json() -> str

Serialize the schema to a JSON string.

Returns:

Name Type Description
str str

The JSON encoded representation of the schema.

Source code in tempest_fastapi_sdk/schemas/base.py
def to_json(self) -> str:
    """Serialize the schema to a JSON string.

    Returns:
        str: The JSON encoded representation of the schema.
    """
    return self.model_dump_json()

BaseResponseSchema

Bases: BaseSchema

Response schema with the four columns every ORM record carries.

Used as the parent of any *ResponseSchema whose payload mirrors a row from a table inheriting from :class:tempest_fastapi_sdk.db.model.BaseModel. created_at and updated_at are normalized to UTC after validation so the API always emits timezone-aware timestamps regardless of how the DB driver returned them.

Attributes:

Name Type Description
id UUID

The unique identifier of the record.

is_active bool

Whether the record is active (soft-delete convention).

created_at datetime

The creation timestamp, normalized to UTC.

updated_at datetime

The last update timestamp, normalized to UTC.

BasePaginationFilterSchema

Bases: BaseSchema

Base filter schema for paginated list endpoints.

Subclass it to add domain-specific filter fields. The base get_conditions method returns every populated field except the pagination/sort keys, which is the contract expected by :class:tempest_fastapi_sdk.db.repository.BaseRepository.paginate.

Field names and defaults mirror the BaseRepository.paginate keyword arguments so passing the schema straight through works without renaming:

.. code-block:: python

result = await repo.paginate(
    filters=f.get_conditions(),
    order_by=f.order_by,
    page=f.page,
    page_size=f.page_size,
    ascending=f.ascending,
)

Attributes:

Name Type Description
page int

The page number to retrieve (1-indexed).

page_size int

The number of items per page.

order_by str | None

The column name to order by. None falls back to the repository default (created_at descending).

ascending bool

Whether to order ascending. Ignored when order_by is None.

is_active bool | None

Filter by active status. None returns both active and inactive rows.

get_conditions

get_conditions() -> dict[str, Any]

Return the dict of filter conditions for the repository.

Strips the pagination and sort keys so the resulting mapping contains only domain-level filters consumable by :meth:BaseRepository.paginate.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The dictionary of filter conditions.

Source code in tempest_fastapi_sdk/schemas/pagination.py
def get_conditions(self) -> dict[str, Any]:
    """Return the dict of filter conditions for the repository.

    Strips the pagination and sort keys so the resulting mapping
    contains only domain-level filters consumable by
    :meth:`BaseRepository.paginate`.

    Returns:
        dict[str, Any]: The dictionary of filter conditions.
    """
    return self.to_dict(
        exclude=["page", "page_size", "order_by", "ascending"],
    )

BasePaginationSchema

Bases: BaseSchema, Generic[T]

Generic envelope returned by paginated endpoints.

Wraps the page of items together with the pagination metadata the frontend needs to render controls. Field names match the request-side :class:BasePaginationFilterSchema and the repository keyword arguments, so the round-trip stays free of renames.

Attributes:

Name Type Description
items list[T]

The items in the current page.

total int

The total number of items across all pages.

page int

The current page number (1-indexed).

page_size int

The number of items per page.

pages int

The total number of pages.

CursorPaginationFilterSchema

Bases: BaseSchema

Request filter for cursor-based pagination endpoints.

Cursor pagination scales better than offset pagination on large tables (no COUNT(*), stable under concurrent inserts) at the cost of losing random-access semantics. Subclass to add domain filters; :meth:get_conditions strips the cursor/sort keys automatically.

Attributes:

Name Type Description
cursor str | None

Opaque cursor returned by the previous page. None requests the first page.

limit int

Maximum number of items to return.

order_by str

Column to sort by. Must be a sortable column with a stable secondary tie-break (id is appended automatically by the repository).

ascending bool

Whether to sort ascending. Defaults to False so newest rows surface first.

get_conditions

get_conditions() -> dict[str, Any]

Return only the domain-level filter conditions.

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The filters with pagination/sort keys

dict[str, Any]

stripped.

Source code in tempest_fastapi_sdk/schemas/pagination.py
def get_conditions(self) -> dict[str, Any]:
    """Return only the domain-level filter conditions.

    Returns:
        dict[str, Any]: The filters with pagination/sort keys
        stripped.
    """
    return self.to_dict(
        exclude=["cursor", "limit", "order_by", "ascending"],
    )

CursorPaginationSchema

Bases: BaseSchema, Generic[T]

Generic envelope returned by cursor-paginated endpoints.

Attributes:

Name Type Description
items list[T]

The items in the current page.

next_cursor str | None

Cursor to request the next page, or None when no more results exist.

has_more bool

Whether another page is available.

limit int

The page size used to produce this payload.

LogEntrySchema

Bases: BaseSchema

A single structured log record parsed from a JSON log file.

The SDK's :class:tempest_fastapi_sdk.JSONFormatter writes one JSON object per line. This schema mirrors its core fields and accepts any additional extra={...} keys (e.g. path, request_id, http_500) via extra="allow" so nothing is silently dropped by the /logs endpoint.

Attributes:

Name Type Description
timestamp str

ISO-8601 UTC timestamp (...Z).

level str

Log level name ("INFO", "ERROR", ...).

logger str

Name of the logger that emitted the record.

message str

The formatted log message.

request_id str | None

Correlation ID when present.

exception str | None

Formatted traceback when the record carried exc_info.


Services & Controllers

BaseService

BaseService(repository: RepositoryT)

Bases: Generic[RepositoryT, ResponseT]

Thin business-logic layer wrapping a :class:BaseRepository.

The default implementation exposes CRUD pass-through methods that delegate to the repository and apply map_to_response so the surface matches what routers/controllers consume. Concrete services should override methods that involve orchestration (multi-repository writes, external side effects, domain rules); pure pass-through methods can be left untouched.

Generic parameters

RepositoryT: The concrete repository class. ResponseT: The response schema returned by the service.

Attributes:

Name Type Description
repository RepositoryT

The repository the service delegates to.

Initialize the service.

Parameters:

Name Type Description Default
repository RepositoryT

The repository to delegate to.

required
Source code in tempest_fastapi_sdk/services/base.py
def __init__(self, repository: RepositoryT) -> None:
    """Initialize the service.

    Args:
        repository (RepositoryT): The repository to delegate to.
    """
    self.repository: RepositoryT = repository

get_by_id async

get_by_id(id: UUID) -> ResponseT

Fetch a single record by primary key and map it to a response.

Parameters:

Name Type Description Default
id UUID

The primary key.

required

Returns:

Name Type Description
ResponseT ResponseT

The mapped response.

Raises:

Type Description
AppException

repository.not_found_exception when no record matches.

Source code in tempest_fastapi_sdk/services/base.py
async def get_by_id(self, id: UUID) -> ResponseT:
    """Fetch a single record by primary key and map it to a response.

    Args:
        id (UUID): The primary key.

    Returns:
        ResponseT: The mapped response.

    Raises:
        AppException: ``repository.not_found_exception`` when no
            record matches.
    """
    instance = await self.repository.get_by_id(id)
    return await self._map_to_response(instance)

get_or_none async

get_or_none(filters: dict[str, Any]) -> ResponseT | None

Return the matching record (mapped) or None.

Parameters:

Name Type Description Default
filters dict[str, Any]

Filter conditions.

required

Returns:

Type Description
ResponseT | None

ResponseT | None: The mapped response, or None.

Source code in tempest_fastapi_sdk/services/base.py
async def get_or_none(self, filters: dict[str, Any]) -> ResponseT | None:
    """Return the matching record (mapped) or ``None``.

    Args:
        filters (dict[str, Any]): Filter conditions.

    Returns:
        ResponseT | None: The mapped response, or ``None``.
    """
    instance = await self.repository.get_or_none(filters)
    if instance is None:
        return None
    return await self._map_to_response(instance)

list async

list(
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> list[ResponseT]

Return every matching record mapped to a response.

Returns [] when nothing matches, in line with the SDK collection convention (never raises *NotFoundError for empty result sets).

Parameters:

Name Type Description Default
filters dict[str, Any] | None

Filter conditions.

None
order_by Any | None

A SQLAlchemy column expression to order by.

None
ascending bool

Whether to order ascending.

True

Returns:

Type Description
list[ResponseT]

list[ResponseT]: The mapped responses.

Source code in tempest_fastapi_sdk/services/base.py
async def list(
    self,
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> list[ResponseT]:
    """Return every matching record mapped to a response.

    Returns ``[]`` when nothing matches, in line with the SDK
    collection convention (never raises ``*NotFoundError`` for
    empty result sets).

    Args:
        filters (dict[str, Any] | None): Filter conditions.
        order_by: A SQLAlchemy column expression to order by.
        ascending (bool): Whether to order ascending.

    Returns:
        list[ResponseT]: The mapped responses.
    """
    instances = await self.repository.list(
        filters=filters,
        order_by=order_by,
        ascending=ascending,
    )
    return [await self._map_to_response(i) for i in instances]

paginate async

paginate(
    filters: dict[str, Any] | None = None,
    order_by: str | None = None,
    page: int = 1,
    page_size: int = 20,
    ascending: bool = True,
) -> dict[str, Any]

Return an offset page with items mapped to responses.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

Filter conditions.

None
order_by str | None

Column name to order by.

None
page int

1-indexed page number.

1
page_size int

Items per page.

20
ascending bool

Whether to order ascending.

True

Returns:

Type Description
dict[str, Any]

dict[str, Any]: Mapping with items (mapped),

dict[str, Any]

total, page, size and pages.

Source code in tempest_fastapi_sdk/services/base.py
async def paginate(
    self,
    filters: dict[str, Any] | None = None,
    order_by: str | None = None,
    page: int = 1,
    page_size: int = 20,
    ascending: bool = True,
) -> dict[str, Any]:
    """Return an offset page with items mapped to responses.

    Args:
        filters (dict[str, Any] | None): Filter conditions.
        order_by (str | None): Column name to order by.
        page (int): 1-indexed page number.
        page_size (int): Items per page.
        ascending (bool): Whether to order ascending.

    Returns:
        dict[str, Any]: Mapping with ``items`` (mapped),
        ``total``, ``page``, ``size`` and ``pages``.
    """
    result = await self.repository.paginate(
        filters=filters,
        order_by=order_by,
        page=page,
        page_size=page_size,
        ascending=ascending,
    )
    items = [await self._map_to_response(i) for i in result["items"]]
    return {**result, "items": items}

count async

count(filters: dict[str, Any] | None = None) -> int

Count the rows matching filters.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

The filter conditions.

None

Returns:

Name Type Description
int int

The matching row count.

Source code in tempest_fastapi_sdk/services/base.py
async def count(self, filters: dict[str, Any] | None = None) -> int:
    """Count the rows matching ``filters``.

    Args:
        filters (dict[str, Any] | None): The filter conditions.

    Returns:
        int: The matching row count.
    """
    return await self.repository.count(filters)

exists async

exists(filters: dict[str, Any]) -> bool

Whether at least one row matches filters.

Parameters:

Name Type Description Default
filters dict[str, Any]

Filter conditions.

required

Returns:

Name Type Description
bool bool

True if at least one row matches.

Source code in tempest_fastapi_sdk/services/base.py
async def exists(self, filters: dict[str, Any]) -> bool:
    """Whether at least one row matches ``filters``.

    Args:
        filters (dict[str, Any]): Filter conditions.

    Returns:
        bool: ``True`` if at least one row matches.
    """
    return await self.repository.exists(filters)

delete async

delete(id: UUID) -> None

Delete a row by primary key.

Parameters:

Name Type Description Default
id UUID

The primary key.

required

Raises:

Type Description
AppException

repository.not_found_exception when no record with id exists.

Source code in tempest_fastapi_sdk/services/base.py
async def delete(self, id: UUID) -> None:
    """Delete a row by primary key.

    Args:
        id (UUID): The primary key.

    Raises:
        AppException: ``repository.not_found_exception`` when no
            record with ``id`` exists.
    """
    await self.repository.delete(id)

BaseController

BaseController(service: ServiceT)

Bases: Generic[ServiceT, ResponseT]

Thin orchestration layer between routers and services.

Following the SDK layering rules (router → controller → service → repository), controllers are kept present even when no orchestration is required so the import graph stays uniform. Override methods here when a single endpoint needs to call multiple services or apply cross-cutting policy; leave the pass-throughs untouched otherwise.

Generic parameters

ServiceT: The concrete service class. ResponseT: The response schema returned to the router.

Attributes:

Name Type Description
service ServiceT

The service the controller delegates to.

Initialize the controller.

Parameters:

Name Type Description Default
service ServiceT

The service to delegate to.

required
Source code in tempest_fastapi_sdk/controllers/base.py
def __init__(self, service: ServiceT) -> None:
    """Initialize the controller.

    Args:
        service (ServiceT): The service to delegate to.
    """
    self.service: ServiceT = service

get_by_id async

get_by_id(id: UUID) -> ResponseT

Pass-through to :meth:BaseService.get_by_id.

Parameters:

Name Type Description Default
id UUID

The primary key.

required

Returns:

Name Type Description
ResponseT ResponseT

The mapped response.

Source code in tempest_fastapi_sdk/controllers/base.py
async def get_by_id(self, id: UUID) -> ResponseT:
    """Pass-through to :meth:`BaseService.get_by_id`.

    Args:
        id (UUID): The primary key.

    Returns:
        ResponseT: The mapped response.
    """
    return cast("ResponseT", await self.service.get_by_id(id))

list async

list(
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> list[ResponseT]

Pass-through to :meth:BaseService.list.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

Filter conditions.

None
order_by Any | None

A SQLAlchemy column expression.

None
ascending bool

Whether to order ascending.

True

Returns:

Type Description
list[ResponseT]

list[ResponseT]: The mapped responses.

Source code in tempest_fastapi_sdk/controllers/base.py
async def list(
    self,
    filters: dict[str, Any] | None = None,
    order_by: Any | None = None,
    ascending: bool = True,
) -> list[ResponseT]:
    """Pass-through to :meth:`BaseService.list`.

    Args:
        filters (dict[str, Any] | None): Filter conditions.
        order_by: A SQLAlchemy column expression.
        ascending (bool): Whether to order ascending.

    Returns:
        list[ResponseT]: The mapped responses.
    """
    return cast(
        "list[ResponseT]",
        await self.service.list(
            filters=filters,
            order_by=order_by,
            ascending=ascending,
        ),
    )

paginate async

paginate(
    filters: dict[str, Any] | None = None,
    order_by: str | None = None,
    page: int = 1,
    page_size: int = 20,
    ascending: bool = True,
) -> dict[str, Any]

Pass-through to :meth:BaseService.paginate.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

Filter conditions.

None
order_by str | None

Column name to order by.

None
page int

1-indexed page number.

1
page_size int

Items per page.

20
ascending bool

Whether to order ascending.

True

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The paginated payload.

Source code in tempest_fastapi_sdk/controllers/base.py
async def paginate(
    self,
    filters: dict[str, Any] | None = None,
    order_by: str | None = None,
    page: int = 1,
    page_size: int = 20,
    ascending: bool = True,
) -> dict[str, Any]:
    """Pass-through to :meth:`BaseService.paginate`.

    Args:
        filters (dict[str, Any] | None): Filter conditions.
        order_by (str | None): Column name to order by.
        page (int): 1-indexed page number.
        page_size (int): Items per page.
        ascending (bool): Whether to order ascending.

    Returns:
        dict[str, Any]: The paginated payload.
    """
    return await self.service.paginate(
        filters=filters,
        order_by=order_by,
        page=page,
        page_size=page_size,
        ascending=ascending,
    )

count async

count(filters: dict[str, Any] | None = None) -> int

Pass-through to :meth:BaseService.count.

Parameters:

Name Type Description Default
filters dict[str, Any] | None

The filter conditions.

None

Returns:

Name Type Description
int int

The matching row count.

Source code in tempest_fastapi_sdk/controllers/base.py
async def count(self, filters: dict[str, Any] | None = None) -> int:
    """Pass-through to :meth:`BaseService.count`.

    Args:
        filters (dict[str, Any] | None): The filter conditions.

    Returns:
        int: The matching row count.
    """
    return await self.service.count(filters)

delete async

delete(id: UUID) -> None

Pass-through to :meth:BaseService.delete.

Parameters:

Name Type Description Default
id UUID

The primary key.

required
Source code in tempest_fastapi_sdk/controllers/base.py
async def delete(self, id: UUID) -> None:
    """Pass-through to :meth:`BaseService.delete`.

    Args:
        id (UUID): The primary key.
    """
    await self.service.delete(id)

Exceções

tempest_fastapi_sdk.exceptions

AppException

AppException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: HTTPException

Base exception for all application-level errors.

Concrete projects raise either a domain-specific subclass (kept around for except DomainError matching) or the base directly, passing code / status_code / message via constructor keyword arguments. Class-level attributes are the defaults each constructor argument falls back to, never required overrides::

class UserNotFoundError(NotFoundException):
    """Subclass exists only for isinstance/except matching."""

raise UserNotFoundError(
    "Usuário não encontrado",
    code="USER_NOT_FOUND",
    details={"email": email},
)

The matching exception handler (see :mod:tempest_fastapi_sdk.api.handlers) emits the JSON shape::

{
    "detail": "<message>",
    "code": "<code>",
    "details": {"<any>": "<context>"}
}

Class attributes (defaults the constructor falls back to): status_code (int): HTTP status code. message (str): Default human-readable message. code (str): Stable, machine-readable identifier.

Instance attributes

status_code (int): The status code attached to this instance. code (str): The error code attached to this instance. details (dict[str, Any]): Free-form context attached to the response payload.

Initialize the exception.

Parameters:

Name Type Description Default
message str | None

Override the class-level message.

None
code str | None

Override the class-level error code on this instance only — leaves other instances of the same class untouched.

None
status_code int | None

Override the class-level HTTP status code on this instance only.

None
details dict[str, Any] | None

Structured context to attach to the JSON response.

None
headers dict[str, str] | None

Optional HTTP headers to include in the response.

None
Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

NotFoundException

NotFoundException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when a single resource cannot be located.

Use for get_by_id / get_by_email style lookups. NEVER use for collection endpoints — those should return [] instead.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ConflictException

ConflictException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when a write would violate a uniqueness/integrity rule.

Typically surfaced by the repository when SQLAlchemy raises an IntegrityError on insert/update.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

UnauthorizedException

UnauthorizedException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when the caller is not authenticated.

Use for missing/invalid/expired credentials. For "authenticated but not allowed" cases, use :class:tempest_fastapi_sdk.exceptions.forbidden.ForbiddenException.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ForbiddenException

ForbiddenException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when the caller is authenticated but lacks permission.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ValidationException

ValidationException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when input fails a business rule beyond Pydantic.

Pydantic emits 422 automatically for schema validation; use this for downstream rules that only the service layer can enforce.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

TooManyRequestsException

TooManyRequestsException(
    message: str | None = None,
    *,
    retry_after_seconds: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when a client exceeds a rate limit or attempt budget.

Carries an optional Retry-After header (seconds) and mirrors the same value under details["retry_after_seconds"] so clients can back off without parsing headers. Used by :class:tempest_fastapi_sdk.utils.AttemptThrottle and suitable for any throttled flow (login, OTP, code verification).

Initialize the exception.

Parameters:

Name Type Description Default
message str | None

Override the class-level message.

None
retry_after_seconds int | None

Cooldown in seconds. When given, sets the Retry-After header and adds retry_after_seconds to details (unless already provided by the caller).

None
details dict[str, Any] | None

Structured context.

None
headers dict[str, str] | None

Extra response headers; merged with the Retry-After header when applicable.

None
Source code in tempest_fastapi_sdk/exceptions/too_many_requests.py
def __init__(
    self,
    message: str | None = None,
    *,
    retry_after_seconds: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        retry_after_seconds (int | None): Cooldown in seconds. When
            given, sets the ``Retry-After`` header and adds
            ``retry_after_seconds`` to ``details`` (unless already
            provided by the caller).
        details (dict[str, Any] | None): Structured context.
        headers (dict[str, str] | None): Extra response headers;
            merged with the ``Retry-After`` header when applicable.
    """
    merged_details: dict[str, Any] = dict(details or {})
    merged_headers: dict[str, str] = dict(headers or {})
    if retry_after_seconds is not None:
        merged_details.setdefault(
            "retry_after_seconds",
            retry_after_seconds,
        )
        merged_headers.setdefault(
            "Retry-After",
            str(retry_after_seconds),
        )
    super().__init__(
        message=message,
        details=merged_details,
        headers=merged_headers or None,
    )

InvalidTokenException

InvalidTokenException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: UnauthorizedException

Raised when a JWT fails signature or claim validation.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

ExpiredTokenException

ExpiredTokenException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: UnauthorizedException

Raised when a JWT's exp claim is in the past.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

FileTooLargeException

FileTooLargeException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when an uploaded file exceeds the configured size limit.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

InvalidFileTypeException

InvalidFileTypeException(
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
)

Bases: AppException

Raised when an uploaded file's extension or MIME is not allowed.

Source code in tempest_fastapi_sdk/exceptions/base.py
def __init__(
    self,
    message: str | None = None,
    *,
    code: str | None = None,
    status_code: int | None = None,
    details: dict[str, Any] | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Initialize the exception.

    Args:
        message (str | None): Override the class-level message.
        code (str | None): Override the class-level error code on
            this instance only — leaves other instances of the
            same class untouched.
        status_code (int | None): Override the class-level HTTP
            status code on this instance only.
        details (dict[str, Any] | None): Structured context to
            attach to the JSON response.
        headers (dict[str, str] | None): Optional HTTP headers
            to include in the response.
    """
    cls = type(self)
    self.code: str = code if code is not None else cls.code
    effective_status: int = (
        status_code if status_code is not None else cls.status_code
    )
    self.details: dict[str, Any] = details or {}
    super().__init__(
        status_code=effective_status,
        detail=message or cls.message,
        headers=headers,
    )

API (integração FastAPI)

tempest_fastapi_sdk.api

register_exception_handlers

register_exception_handlers(
    app: FastAPI,
    *,
    log_traceback: bool = True,
    include_traceback: bool = False,
    log_level: int = ERROR,
) -> None

Register the SDK's exception handlers on a FastAPI app.

Wires three handlers, in order of specificity:

  • :class:AppException → :func:app_exception_handler. Every domain-specific subclass returned by routers, services and repositories is serialized consistently.
  • :class:starlette.exceptions.HTTPException → :func:make_http_exception_handler factory. raise HTTPException(500, ...) would otherwise bypass the SDK's catch-all (Starlette intercepts HTTPException inside its own middleware), so this handler restores the log + envelope behavior for 5xx HTTPExceptions while leaving 4xx untouched.
  • :class:Exception (catch-all) → traceback logger + generic 500 envelope. Without this, FastAPI's default returns the string "Internal Server Error" with no log entry beyond the access line, leaving operators blind to real failures.

Parameters:

Name Type Description Default
app FastAPI

The FastAPI application to wire.

required
log_traceback bool

Whether the 5xx handlers attach the full traceback to the log record. Defaults to True (always emit the trace). Pass False to silence the trace when an APM / Sentry / equivalent is already capturing the failure.

True
include_traceback bool

When True, the unhandled-500 response body includes the formatted traceback under details.traceback. Use only in development.

False
log_level int

Logging level used by the 5xx handlers. Defaults to :data:logging.ERROR.

ERROR
Source code in tempest_fastapi_sdk/api/handlers.py
def register_exception_handlers(
    app: FastAPI,
    *,
    log_traceback: bool = True,
    include_traceback: bool = False,
    log_level: int = logging.ERROR,
) -> None:
    """Register the SDK's exception handlers on a FastAPI app.

    Wires three handlers, in order of specificity:

    * :class:`AppException` → :func:`app_exception_handler`. Every
      domain-specific subclass returned by routers, services and
      repositories is serialized consistently.
    * :class:`starlette.exceptions.HTTPException` →
      :func:`make_http_exception_handler` factory. ``raise
      HTTPException(500, ...)`` would otherwise bypass the SDK's
      catch-all (Starlette intercepts HTTPException inside its own
      middleware), so this handler restores the log + envelope
      behavior for 5xx HTTPExceptions while leaving 4xx untouched.
    * :class:`Exception` (catch-all) → traceback logger + generic
      500 envelope. Without this, FastAPI's default returns the
      string ``"Internal Server Error"`` with no log entry beyond
      the access line, leaving operators blind to real failures.

    Args:
        app (FastAPI): The FastAPI application to wire.
        log_traceback (bool): Whether the 5xx handlers attach the
            full traceback to the log record. Defaults to ``True``
            (always emit the trace). Pass ``False`` to silence the
            trace when an APM / Sentry / equivalent is already
            capturing the failure.
        include_traceback (bool): When ``True``, the unhandled-500
            response body includes the formatted traceback under
            ``details.traceback``. Use only in development.
        log_level (int): Logging level used by the 5xx handlers.
            Defaults to :data:`logging.ERROR`.
    """
    # Starlette's ``add_exception_handler`` is typed to accept only
    # callables keyed by the broad ``Exception`` — our typed
    # handlers narrow the second arg to ``AppException`` /
    # ``StarletteHTTPException`` for the SDK consumer, so the cast
    # is safe at the boundary.
    app.add_exception_handler(
        AppException,
        make_app_exception_handler(log_level=log_level),  # type: ignore[arg-type]
    )
    app.add_exception_handler(
        StarletteHTTPException,
        make_http_exception_handler(  # type: ignore[arg-type]
            log_traceback=log_traceback,
            log_level=log_level,
        ),
    )
    app.add_exception_handler(
        Exception,
        make_unhandled_exception_handler(
            log_traceback=log_traceback,
            include_traceback=include_traceback,
            log_level=log_level,
        ),
    )

make_app_exception_handler

make_app_exception_handler(*, log_level: int = INFO) -> AppExceptionHandler

Build the handler for :class:AppException subclasses.

Serializes the exception to the SDK envelope and emits an INFO-level log line (no traceback — 4xx is normal client flow). 5xx AppException subclasses bump up to log_level with a traceback and the :data:HTTP_500_MARKER flag so 500.log captures them.

Parameters:

Name Type Description Default
log_level int

Level used only for 5xx AppException records (the 4xx path always logs at INFO regardless, since elevating client errors to WARN/ERROR adds noise). Defaults to :data:logging.INFO; pass logging.ERROR (or pass log_level=logging.ERROR through :func:register_exception_handlers) when 5xx AppException subclasses should trigger paging.

INFO

Returns:

Name Type Description
AppExceptionHandler AppExceptionHandler

An async (request, exc) -> JSONResponse

AppExceptionHandler

callable.

Source code in tempest_fastapi_sdk/api/handlers.py
def make_app_exception_handler(
    *,
    log_level: int = logging.INFO,
) -> AppExceptionHandler:
    """Build the handler for :class:`AppException` subclasses.

    Serializes the exception to the SDK envelope and emits an
    ``INFO``-level log line (no traceback — 4xx is normal client
    flow). ``5xx`` ``AppException`` subclasses bump up to
    ``log_level`` with a traceback and the
    :data:`HTTP_500_MARKER` flag so ``500.log`` captures them.

    Args:
        log_level (int): Level used **only** for 5xx ``AppException``
            records (the 4xx path always logs at ``INFO`` regardless,
            since elevating client errors to WARN/ERROR adds noise).
            Defaults to :data:`logging.INFO`; pass ``logging.ERROR``
            (or pass ``log_level=logging.ERROR`` through
            :func:`register_exception_handlers`) when 5xx
            ``AppException`` subclasses should trigger paging.

    Returns:
        AppExceptionHandler: An async ``(request, exc) -> JSONResponse``
        callable.
    """

    async def _handler(
        request: Request,
        exc: AppException,
    ) -> JSONResponse:
        request_id = (
            get_request_id()
            or request.headers.get("X-Request-ID")
            or request.headers.get("x-request-id")
        )
        is_server_error = exc.status_code >= 500
        extra: dict[str, Any] = {
            "request_id": request_id,
            "path": request.url.path,
            "status_code": exc.status_code,
            "code": exc.code,
        }
        if is_server_error:
            extra[HTTP_500_MARKER] = True
        logger.log(
            log_level if is_server_error else logging.INFO,
            "AppException %s (%s) during %s %s: %s",
            exc.status_code,
            exc.code,
            request.method,
            request.url.path,
            exc.detail,
            exc_info=exc if is_server_error else None,
            extra=extra,
        )
        return JSONResponse(
            status_code=exc.status_code,
            content={
                "detail": exc.detail,
                "code": exc.code,
                "details": exc.details,
            },
            headers=exc.headers,
        )

    return _handler

make_http_exception_handler

make_http_exception_handler(
    *, log_traceback: bool = True, log_level: int = ERROR
) -> HTTPExceptionHandler

Build the handler for raw :class:starlette.exceptions.HTTPException.

Without this, raise HTTPException(500, "...") (or 404, 403, …) bypasses the SDK's Exception catch-all entirely: Starlette intercepts HTTPException instances inside its ExceptionMiddleware and routes them to its own default — a bare JSONResponse({"detail": exc.detail}) with no log entry. Operators see the 500 in the access log and no trace.

This handler closes that gap for 5xx HTTPExceptions:

  1. Whenever exc.status_code >= 500, the failure is logged at log_level (ERROR by default) under tempest_fastapi_sdk.api.handlers. The record is flagged with :data:HTTP_500_MARKER so configure_logging(log_dir=…) routes it to the dedicated 500.log alongside the trace.
  2. The response keeps the original status_code / headers and adds the SDK envelope shape (detail / code / details), so frontends consuming the same envelope across :class:AppException and raw HTTPException don't need to branch.

4xx HTTPExceptions are returned untouched (Starlette's default behavior), since those represent normal client-side outcomes that don't deserve a stack trace.

Parameters:

Name Type Description Default
log_traceback bool

Whether to attach exc_info=exc to the 5xx log record. True by default.

True
log_level int

Logging level used for 5xx records.

ERROR

Returns:

Name Type Description
HTTPExceptionHandler HTTPExceptionHandler

An async

HTTPExceptionHandler

(request, exc) -> JSONResponse callable ready to pass to

HTTPExceptionHandler

meth:FastAPI.add_exception_handler.

Source code in tempest_fastapi_sdk/api/handlers.py
def make_http_exception_handler(
    *,
    log_traceback: bool = True,
    log_level: int = logging.ERROR,
) -> HTTPExceptionHandler:
    """Build the handler for raw :class:`starlette.exceptions.HTTPException`.

    Without this, ``raise HTTPException(500, "...")`` (or ``404``,
    ``403``, …) bypasses the SDK's ``Exception`` catch-all entirely:
    Starlette intercepts ``HTTPException`` instances inside its
    ``ExceptionMiddleware`` and routes them to its own default — a
    bare ``JSONResponse({"detail": exc.detail})`` with no log entry.
    Operators see the 500 in the access log and *no* trace.

    This handler closes that gap for 5xx HTTPExceptions:

    1. Whenever ``exc.status_code >= 500``, the failure is logged at
       ``log_level`` (ERROR by default) under
       ``tempest_fastapi_sdk.api.handlers``. The record is flagged
       with :data:`HTTP_500_MARKER` so ``configure_logging(log_dir=…)``
       routes it to the dedicated ``500.log`` alongside the trace.
    2. The response keeps the original ``status_code`` /
       ``headers`` and adds the SDK envelope shape
       (``detail`` / ``code`` / ``details``), so frontends consuming
       the same envelope across :class:`AppException` and raw
       ``HTTPException`` don't need to branch.

    4xx HTTPExceptions are returned untouched (Starlette's default
    behavior), since those represent normal client-side outcomes that
    don't deserve a stack trace.

    Args:
        log_traceback (bool): Whether to attach ``exc_info=exc`` to
            the 5xx log record. ``True`` by default.
        log_level (int): Logging level used for 5xx records.

    Returns:
        HTTPExceptionHandler: An async
        ``(request, exc) -> JSONResponse`` callable ready to pass to
        :meth:`FastAPI.add_exception_handler`.
    """

    async def _handler(
        request: Request,
        exc: StarletteHTTPException,
    ) -> JSONResponse:
        request_id = (
            get_request_id()
            or request.headers.get("X-Request-ID")
            or request.headers.get("x-request-id")
        )
        if exc.status_code >= 500:
            logger.log(
                log_level,
                "HTTPException %s during %s %s: %s",
                exc.status_code,
                request.method,
                request.url.path,
                exc.detail,
                exc_info=exc if log_traceback else None,
                extra={
                    "request_id": request_id,
                    "path": request.url.path,
                    "status_code": exc.status_code,
                    HTTP_500_MARKER: True,
                },
            )
            body: dict[str, Any] = {
                "detail": str(exc.detail or "Internal server error"),
                "code": "INTERNAL_SERVER_ERROR",
                "details": ({"request_id": request_id} if request_id else {}),
            }
            return JSONResponse(
                status_code=exc.status_code,
                content=body,
                headers=getattr(exc, "headers", None),
            )
        # 4xx — INFO-level log (no traceback, no 500.log marker) so
        # operators can still see the request that failed without
        # paying the cost of a stack trace.
        logger.log(
            logging.INFO,
            "HTTPException %s during %s %s: %s",
            exc.status_code,
            request.method,
            request.url.path,
            exc.detail,
            extra={
                "request_id": request_id,
                "path": request.url.path,
                "status_code": exc.status_code,
            },
        )
        return JSONResponse(
            status_code=exc.status_code,
            content={"detail": exc.detail},
            headers=getattr(exc, "headers", None),
        )

    return _handler

make_unhandled_exception_handler

make_unhandled_exception_handler(
    *,
    log_traceback: bool = True,
    include_traceback: bool = False,
    log_level: int = ERROR,
) -> UnhandledExceptionHandler

Build the catch-all handler for non-:class:AppException errors.

Default FastAPI/Starlette behavior on uncaught exceptions is to return a bare Internal Server Error string and emit nothing beyond the access log line — the actual traceback never reaches the logger and never reaches the operator. This handler closes that gap:

  1. Logs the failure at log_level (ERROR by default) under the tempest_fastapi_sdk.api.handlers logger. When log_traceback=True (the default), the full traceback is attached via exc_info so the application's LogUtils / configure_logging setup serializes it. The record is flagged with :data:tempest_fastapi_sdk.core.logging.HTTP_500_MARKER so configure_logging(log_dir=...) can route it to a dedicated 500.log.
  2. Returns the canonical SDK JSON envelope with code="INTERNAL_SERVER_ERROR" and status_code=500.
  3. When include_traceback=True (development only) appends the formatted traceback under details.traceback so the failure is visible in the browser too. Leave it off in production — the body would leak module paths, secrets in repr output and SQL fragments.

Parameters:

Name Type Description Default
log_traceback bool

Whether to attach the full traceback to the log record via exc_info. Defaults to True — we want operators to see the cause every time. Pass False only when the trace would be noisy AND the failure is already being captured elsewhere (e.g. an APM agent).

True
include_traceback bool

Whether to surface the traceback in the response body. Off in production.

False
log_level int

Logging level used by the catch-all handler.

ERROR

Returns:

Name Type Description
UnhandledExceptionHandler UnhandledExceptionHandler

An async

UnhandledExceptionHandler

(request, exc) -> JSONResponse callable ready to pass to

UnhandledExceptionHandler

meth:FastAPI.add_exception_handler.

Source code in tempest_fastapi_sdk/api/handlers.py
def make_unhandled_exception_handler(
    *,
    log_traceback: bool = True,
    include_traceback: bool = False,
    log_level: int = logging.ERROR,
) -> UnhandledExceptionHandler:
    """Build the catch-all handler for non-:class:`AppException` errors.

    Default FastAPI/Starlette behavior on uncaught exceptions is to
    return a bare ``Internal Server Error`` string and emit nothing
    beyond the access log line — the actual traceback never reaches
    the logger and never reaches the operator. This handler closes
    that gap:

    1. Logs the failure at ``log_level`` (ERROR by default) under the
       ``tempest_fastapi_sdk.api.handlers`` logger. When
       ``log_traceback=True`` (the default), the full traceback is
       attached via ``exc_info`` so the application's
       ``LogUtils`` / ``configure_logging`` setup serializes it. The
       record is flagged with
       :data:`tempest_fastapi_sdk.core.logging.HTTP_500_MARKER` so
       ``configure_logging(log_dir=...)`` can route it to a dedicated
       ``500.log``.
    2. Returns the canonical SDK JSON envelope with
       ``code="INTERNAL_SERVER_ERROR"`` and ``status_code=500``.
    3. When ``include_traceback=True`` (development only) appends
       the formatted traceback under ``details.traceback`` so the
       failure is visible in the browser too. Leave it off in
       production — the body would leak module paths, secrets in
       ``repr`` output and SQL fragments.

    Args:
        log_traceback (bool): Whether to attach the full traceback to
            the log record via ``exc_info``. Defaults to ``True`` — we
            want operators to see the cause every time. Pass ``False``
            only when the trace would be noisy AND the failure is
            already being captured elsewhere (e.g. an APM agent).
        include_traceback (bool): Whether to surface the traceback in
            the *response body*. Off in production.
        log_level (int): Logging level used by the catch-all handler.

    Returns:
        UnhandledExceptionHandler: An async
        ``(request, exc) -> JSONResponse`` callable ready to pass to
        :meth:`FastAPI.add_exception_handler`.
    """

    async def _handler(request: Request, exc: Exception) -> JSONResponse:
        # contextvar first (works for plain ASGI middlewares), then the
        # inbound X-Request-ID header (BaseHTTPMiddleware spawns a child
        # task so its contextvars don't always reach the exception
        # handler), then None.
        request_id = (
            get_request_id()
            or request.headers.get("X-Request-ID")
            or request.headers.get("x-request-id")
        )
        logger.log(
            log_level,
            "Unhandled exception during %s %s",
            request.method,
            request.url.path,
            exc_info=exc if log_traceback else None,
            extra={
                "request_id": request_id,
                "path": request.url.path,
                HTTP_500_MARKER: True,
            },
        )
        body: dict[str, Any] = {
            "detail": "Internal server error",
            "code": "INTERNAL_SERVER_ERROR",
            "details": ({"request_id": request_id} if request_id else {}),
        }
        if include_traceback:
            body["details"]["traceback"] = traceback.format_exception(
                type(exc), exc, exc.__traceback__
            )
        return JSONResponse(status_code=500, content=body)

    return _handler

RequestIDMiddleware

RequestIDMiddleware(app: ASGIApp, header_name: str = 'X-Request-ID')

Bases: BaseHTTPMiddleware

Bind an X-Request-ID header to the request-scoped context.

Reads the inbound header (or generates a fresh UUID v4 when absent), stores it via :func:set_request_id so log records written during the request carry the request_id field, and echoes the same value back on the response so callers can trace end-to-end across services.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI application.

required
header_name str

The header to read/write. Defaults to "X-Request-ID".

'X-Request-ID'
Source code in tempest_fastapi_sdk/api/middlewares/request_id.py
def __init__(
    self,
    app: ASGIApp,
    header_name: str = "X-Request-ID",
) -> None:
    super().__init__(app)
    self.header_name: str = header_name

dispatch async

dispatch(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response

Run the wrapped handler with a bound request ID.

Parameters:

Name Type Description Default
request Request

The inbound request.

required
call_next Callable[[Request], Awaitable[Response]]

The downstream ASGI handler.

required

Returns:

Name Type Description
Response Response

The handler's response with the request ID

Response

echoed in the configured header.

Source code in tempest_fastapi_sdk/api/middlewares/request_id.py
async def dispatch(
    self,
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    """Run the wrapped handler with a bound request ID.

    Args:
        request (Request): The inbound request.
        call_next: The downstream ASGI handler.

    Returns:
        Response: The handler's response with the request ID
        echoed in the configured header.
    """
    inbound = request.headers.get(self.header_name)
    rid = (
        inbound
        if inbound is not None and _VALID_REQUEST_ID.fullmatch(inbound)
        else str(uuid.uuid4())
    )
    token = set_request_id(rid)
    try:
        response = await call_next(request)
    finally:
        clear_request_id(token)
    response.headers[self.header_name] = rid
    return response

IdempotencyMiddleware

IdempotencyMiddleware(
    app: ASGIApp,
    *,
    store: IdempotencyStore,
    ttl_seconds: int = 24 * 3600,
    header_name: str = IDEMPOTENCY_HEADER,
)

Bases: BaseHTTPMiddleware

ASGI middleware caching responses by Idempotency-Key.

Only mutating verbs (POST / PUT / PATCH / DELETE) are eligible. The key is scoped per (method, path, key) so a key reused across different endpoints doesn't collide.

Add to FastAPI like any other ASGI middleware:

from tempest_fastapi_sdk import (
    IdempotencyMiddleware,
    MemoryIdempotencyStore,
)

app.add_middleware(
    IdempotencyMiddleware,
    store=MemoryIdempotencyStore(),
    ttl_seconds=24 * 3600,
)

Initialize the middleware.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI app.

required
store IdempotencyStore

Backend used to cache responses. Pass :class:MemoryIdempotencyStore for single-replica deployments, :class:RedisIdempotencyStore otherwise.

required
ttl_seconds int

How long to keep cached responses. Stripe defaults to 24 hours — long enough to cover client retries with exponential backoff.

24 * 3600
header_name str

Header carrying the idempotency key. Defaults to the canonical Idempotency-Key.

IDEMPOTENCY_HEADER
Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
def __init__(
    self,
    app: ASGIApp,
    *,
    store: IdempotencyStore,
    ttl_seconds: int = 24 * 3600,
    header_name: str = IDEMPOTENCY_HEADER,
) -> None:
    """Initialize the middleware.

    Args:
        app (ASGIApp): The wrapped ASGI app.
        store (IdempotencyStore): Backend used to cache responses.
            Pass :class:`MemoryIdempotencyStore` for single-replica
            deployments, :class:`RedisIdempotencyStore` otherwise.
        ttl_seconds (int): How long to keep cached responses.
            Stripe defaults to 24 hours — long enough to cover
            client retries with exponential backoff.
        header_name (str): Header carrying the idempotency key.
            Defaults to the canonical ``Idempotency-Key``.
    """
    super().__init__(app)
    self.store: IdempotencyStore = store
    self.ttl_seconds: int = ttl_seconds
    self.header_name: str = header_name

dispatch async

dispatch(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response

Replay cached responses when the same key reappears.

Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
async def dispatch(
    self,
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    """Replay cached responses when the same key reappears."""
    if request.method not in _MUTATING_METHODS:
        return await call_next(request)

    key = request.headers.get(self.header_name)
    if not key:
        return await call_next(request)

    cache_key = self._build_cache_key(request, key)
    cached = await self.store.get(cache_key)
    if cached is not None:
        return Response(
            content=cached.body,
            status_code=cached.status_code,
            headers=dict(cached.headers),
            media_type=cached.media_type,
        )

    response = await call_next(request)

    body_chunks: list[bytes] = []
    async for chunk in response.body_iterator:  # type: ignore[attr-defined]
        if isinstance(chunk, str):
            chunk = chunk.encode("utf-8")
        body_chunks.append(chunk)
    body = b"".join(body_chunks)

    cached_response = CachedResponse(
        status_code=response.status_code,
        headers=[
            (k.decode("latin-1"), v.decode("latin-1"))
            for k, v in response.raw_headers
        ],
        body=body,
        media_type=response.media_type,
    )
    await self.store.set(
        cache_key,
        cached_response,
        ttl_seconds=self.ttl_seconds,
    )
    return Response(
        content=body,
        status_code=response.status_code,
        headers=dict(cached_response.headers),
        media_type=response.media_type,
    )

MemoryIdempotencyStore

MemoryIdempotencyStore()

In-process :class:IdempotencyStore with TTL eviction.

Single-replica only — a second replica won't see entries stored by the first. Suitable for dev, tests, and small services that haven't scaled out yet.

The eviction is best-effort: TTLs are checked on access; no background thread cleans the dict. Memory grows linearly with cached requests until they expire, so set a sensible TTL.

Initialize the in-memory store.

Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
def __init__(self) -> None:
    """Initialize the in-memory store."""
    self._store: dict[str, tuple[float, CachedResponse]] = {}
    self._lock: asyncio.Lock = asyncio.Lock()

get async

get(key: str) -> CachedResponse | None

Return the cached response, evicting if expired.

Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
async def get(self, key: str) -> CachedResponse | None:
    """Return the cached response, evicting if expired."""
    async with self._lock:
        entry = self._store.get(key)
        if entry is None:
            return None
        expires_at, response = entry
        if expires_at < time.monotonic():
            self._store.pop(key, None)
            return None
        return response

set async

set(key: str, response: CachedResponse, *, ttl_seconds: int) -> None

Store the response with an expiry.

Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
async def set(
    self,
    key: str,
    response: CachedResponse,
    *,
    ttl_seconds: int,
) -> None:
    """Store the response with an expiry."""
    async with self._lock:
        self._store[key] = (time.monotonic() + ttl_seconds, response)

RedisIdempotencyStore

RedisIdempotencyStore(client: _RedisLike, *, prefix: str = 'idem:')

:class:IdempotencyStore backed by an async redis client.

The cached payload is encoded as JSON so the schema stays portable across SDK versions: {"status_code", "headers", "body_b64", "media_type"} with the body base64-encoded because Redis values are bytes.

Use this in production / multi-replica deployments. Requires the [cache] extra so the redis async client is available.

Initialize.

Parameters:

Name Type Description Default
client _RedisLike

Async Redis-like client exposing get(key) / set(key, value, ex) (e.g. redis.asyncio.Redis or any equivalent).

required
prefix str

Key prefix so idempotency entries don't collide with other cached data.

'idem:'
Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
def __init__(
    self,
    client: _RedisLike,
    *,
    prefix: str = "idem:",
) -> None:
    """Initialize.

    Args:
        client (_RedisLike): Async Redis-like client exposing
            ``get(key)`` / ``set(key, value, ex)`` (e.g.
            ``redis.asyncio.Redis`` or any equivalent).
        prefix (str): Key prefix so idempotency entries don't
            collide with other cached data.
    """
    self.client: _RedisLike = client
    self.prefix: str = prefix

get async

get(key: str) -> CachedResponse | None

Fetch and decode the cached response.

Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
async def get(self, key: str) -> CachedResponse | None:
    """Fetch and decode the cached response."""
    import base64

    raw = await self.client.get(self._key(key))
    if raw is None:
        return None
    payload = json.loads(raw)
    return CachedResponse(
        status_code=payload["status_code"],
        headers=[tuple(h) for h in payload["headers"]],
        body=base64.b64decode(payload["body_b64"]),
        media_type=payload.get("media_type"),
    )

set async

set(key: str, response: CachedResponse, *, ttl_seconds: int) -> None

Serialize and write with EXPIRE.

Source code in tempest_fastapi_sdk/api/middlewares/idempotency.py
async def set(
    self,
    key: str,
    response: CachedResponse,
    *,
    ttl_seconds: int,
) -> None:
    """Serialize and write with EXPIRE."""
    import base64

    payload = json.dumps(
        {
            "status_code": response.status_code,
            "headers": list(response.headers),
            "body_b64": base64.b64encode(response.body).decode("ascii"),
            "media_type": response.media_type,
        }
    )
    await self.client.set(self._key(key), payload, ex=ttl_seconds)

BodySizeLimitMiddleware

BodySizeLimitMiddleware(
    app: ASGIApp, *, max_bytes: int, exclude_paths: tuple[str, ...] = ()
)

Pure ASGI middleware enforcing max_bytes per request.

Two checks happen:

  1. Header checkContent-Length greater than the cap short-circuits immediately with a 413 response. This catches the common case where the client knows the size.
  2. Streaming check — for chunked / unknown-length uploads the middleware tracks bytes seen in the http.request messages and aborts once the cap is crossed.

Excluded paths bypass the check entirely (typical use: an upload endpoint that intentionally accepts larger bodies and enforces its own per-route limit).

Initialize.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI app.

required
max_bytes int

Hard cap on the request body in bytes. 0 disables the check (do not ship to production).

required
exclude_paths tuple[str, ...]

Path prefixes that bypass the limit. Match is startswith so the more specific the better.

()
Source code in tempest_fastapi_sdk/api/middlewares/body_size.py
def __init__(
    self,
    app: ASGIApp,
    *,
    max_bytes: int,
    exclude_paths: tuple[str, ...] = (),
) -> None:
    """Initialize.

    Args:
        app (ASGIApp): The wrapped ASGI app.
        max_bytes (int): Hard cap on the request body in bytes.
            ``0`` disables the check (do not ship to production).
        exclude_paths (tuple[str, ...]): Path prefixes that
            bypass the limit. Match is ``startswith`` so the
            more specific the better.
    """
    self.app: ASGIApp = app
    self.max_bytes: int = max_bytes
    self.exclude_paths: tuple[str, ...] = exclude_paths

CSRFMiddleware

CSRFMiddleware(
    app: ASGIApp,
    *,
    cookie_name: str = CSRF_COOKIE_NAME,
    header_name: str = CSRF_HEADER_NAME,
    exclude_paths: tuple[str, ...] = (),
)

Bases: BaseHTTPMiddleware

Double-submit cookie CSRF guard.

On unsafe methods (POST / PUT / PATCH / DELETE) the request MUST carry:

  1. The CSRF cookie (csrf_token by default).
  2. The CSRF header (X-CSRF-Token by default) with the same value.

Missing or mismatched values return 403 with the SDK envelope. Excluded paths bypass the check — typical use: /api/ routes that use Authorization: Bearer (not susceptible to CSRF), or webhook callbacks whose authentication is signature-based.

Safe methods (GET / HEAD / OPTIONS) always pass.

Initialize.

Parameters:

Name Type Description Default
app ASGIApp

Wrapped app.

required
cookie_name str

Name of the CSRF cookie.

CSRF_COOKIE_NAME
header_name str

Name of the CSRF header.

CSRF_HEADER_NAME
exclude_paths tuple[str, ...]

Path prefixes that bypass the check (e.g. ("/api/", "/webhooks/")).

()
Source code in tempest_fastapi_sdk/api/middlewares/csrf.py
def __init__(
    self,
    app: ASGIApp,
    *,
    cookie_name: str = CSRF_COOKIE_NAME,
    header_name: str = CSRF_HEADER_NAME,
    exclude_paths: tuple[str, ...] = (),
) -> None:
    """Initialize.

    Args:
        app (ASGIApp): Wrapped app.
        cookie_name (str): Name of the CSRF cookie.
        header_name (str): Name of the CSRF header.
        exclude_paths (tuple[str, ...]): Path prefixes that
            bypass the check (e.g. ``("/api/", "/webhooks/")``).
    """
    super().__init__(app)
    self.cookie_name: str = cookie_name
    self.header_name: str = header_name
    self.exclude_paths: tuple[str, ...] = exclude_paths

dispatch async

dispatch(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response

Enforce the double-submit check on unsafe methods.

Source code in tempest_fastapi_sdk/api/middlewares/csrf.py
async def dispatch(
    self,
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    """Enforce the double-submit check on unsafe methods."""
    if request.method not in _UNSAFE_METHODS:
        return await call_next(request)
    if self._is_excluded(request.url.path):
        return await call_next(request)

    cookie_token = request.cookies.get(self.cookie_name)
    header_token = request.headers.get(self.header_name)
    if not cookie_token or not header_token:
        return self._reject("CSRF token missing.")
    if not hmac.compare_digest(cookie_token, header_token):
        return self._reject("CSRF token mismatch.")
    return await call_next(request)

make_csrf_token_dependency

make_csrf_token_dependency(
    *, cookie_name: str = CSRF_COOKIE_NAME
) -> Callable[[Request], str]

Build a FastAPI dependency that issues + returns the CSRF token.

Use this on the route that renders the login page (or the HTML shell that triggers form submissions). The dependency sets the cookie on the response when missing, returning the token so the template can embed it as a hidden input or read it via document.cookie.

Parameters:

Name Type Description Default
cookie_name str

Cookie key — must match CSRFMiddleware(cookie_name=…).

CSRF_COOKIE_NAME

Returns:

Type Description
Callable[[Request], str]

Callable[[Request], str]: FastAPI dependency.

Source code in tempest_fastapi_sdk/api/middlewares/csrf.py
def make_csrf_token_dependency(
    *,
    cookie_name: str = CSRF_COOKIE_NAME,
) -> Callable[[Request], str]:
    """Build a FastAPI dependency that issues + returns the CSRF token.

    Use this on the route that renders the login page (or the
    HTML shell that triggers form submissions). The dependency
    sets the cookie on the response when missing, returning the
    token so the template can embed it as a hidden input or read
    it via ``document.cookie``.

    Args:
        cookie_name (str): Cookie key — must match
            ``CSRFMiddleware(cookie_name=…)``.

    Returns:
        Callable[[Request], str]: FastAPI dependency.
    """

    def _ensure_token(request: Request) -> str:
        """Return the existing CSRF token or mint a new one."""
        token = request.cookies.get(cookie_name)
        if token is None:
            token = generate_csrf_token()
        # Stash on request.state so the route handler can read it
        # and call ``response.set_cookie`` itself when needed.
        request.state.csrf_token = token
        return token

    return _ensure_token

generate_csrf_token

generate_csrf_token(n_bytes: int = 32) -> str

Mint a fresh CSRF token.

Parameters:

Name Type Description Default
n_bytes int

Entropy bytes. 32 yields a 43-char URL-safe string; bring this above 16 to stay above the birthday-bound on the cookie set.

32

Returns:

Name Type Description
str str

URL-safe base64 token without padding.

Source code in tempest_fastapi_sdk/api/middlewares/csrf.py
def generate_csrf_token(n_bytes: int = 32) -> str:
    """Mint a fresh CSRF token.

    Args:
        n_bytes (int): Entropy bytes. 32 yields a 43-char URL-safe
            string; bring this above 16 to stay above the
            birthday-bound on the cookie set.

    Returns:
        str: URL-safe base64 token without padding.
    """
    return secrets.token_urlsafe(n_bytes)

LocalUploadStorage

LocalUploadStorage(base_dir: Path | str)

Disk-backed :class:UploadStorage using aiofiles.

Writes chunks under base_dir and refuses keys that resolve outside the base — same path-traversal protection :class:UploadUtils already applied. The base_dir is created (with parents) on instantiation.

Initialize.

Parameters:

Name Type Description Default
base_dir Path | str

Root directory for all writes.

required

Raises:

Type Description
ImportError

When the [upload] extra is not installed.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
def __init__(self, base_dir: Path | str) -> None:
    """Initialize.

    Args:
        base_dir (Path | str): Root directory for all writes.

    Raises:
        ImportError: When the ``[upload]`` extra is not
            installed.
    """
    if _aiofiles is None:
        raise ImportError(
            "LocalUploadStorage requires the [upload] extra. "
            "Install with `pip install tempest-fastapi-sdk[upload]`."
        )
    self.base_dir: Path = Path(base_dir).resolve()
    self.base_dir.mkdir(parents=True, exist_ok=True)

write_stream async

write_stream(
    key: str,
    chunks: AsyncIterator[bytes],
    *,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
    max_size_bytes: int | None = None,
    validator: ContentValidator | None = None,
) -> UploadResult

Persist chunks to base_dir / key.

content_type and metadata are accepted for protocol parity but ignored — the local filesystem has nowhere to store them.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def write_stream(
    self,
    key: str,
    chunks: AsyncIterator[bytes],
    *,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
    max_size_bytes: int | None = None,
    validator: ContentValidator | None = None,
) -> UploadResult:
    """Persist ``chunks`` to ``base_dir / key``.

    ``content_type`` and ``metadata`` are accepted for protocol
    parity but ignored — the local filesystem has nowhere to
    store them.
    """
    from tempest_fastapi_sdk.exceptions.upload import (
        FileTooLargeException,
        InvalidFileTypeException,
    )

    del content_type, metadata  # not stored locally
    assert _aiofiles is not None, "guarded by __init__"
    target = self._resolve(key)
    target.parent.mkdir(parents=True, exist_ok=True)

    total = 0
    first = True
    try:
        async with _aiofiles.open(target, "wb") as out:
            async for chunk in chunks:
                if first:
                    first = False
                    if validator is not None and not validator(chunk):
                        raise InvalidFileTypeException(
                            details={
                                "reason": "validator rejected first chunk",
                            },
                        )
                total += len(chunk)
                if max_size_bytes is not None and total > max_size_bytes:
                    raise FileTooLargeException(
                        details={"max_size_bytes": max_size_bytes},
                    )
                await out.write(chunk)
    except Exception:
        target.unlink(missing_ok=True)
        raise

    return UploadResult(
        key=key,
        size=total,
        path=target,
        url=None,
    )

delete async

delete(key: str) -> bool

Delete base_dir / key when present.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def delete(self, key: str) -> bool:
    """Delete ``base_dir / key`` when present."""
    target = self._resolve(key)
    if not target.exists():
        return False
    target.unlink()
    return True

exists async

exists(key: str) -> bool

Return True when base_dir / key exists.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def exists(self, key: str) -> bool:
    """Return ``True`` when ``base_dir / key`` exists."""
    return self._resolve(key).exists()

presigned_url async

presigned_url(key: str, *, expires: timedelta = timedelta(hours=1)) -> str | None

Local storage cannot mint URLs — always None.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def presigned_url(
    self,
    key: str,
    *,
    expires: timedelta = timedelta(hours=1),
) -> str | None:
    """Local storage cannot mint URLs — always ``None``."""
    del key, expires
    return None

MinIOUploadStorage

MinIOUploadStorage(client: AsyncMinIOClient, *, bucket: str | None = None)

:class:UploadStorage backed by :class:AsyncMinIOClient.

Reuses an existing client instance — typically the one wired on the FastAPI app — so the connection pool is shared. The bucket falls back to the client's default_bucket.

Initialize.

Parameters:

Name Type Description Default
client AsyncMinIOClient

A configured MinIO client.

required
bucket str | None

Target bucket. None uses the client's default_bucket.

None
Source code in tempest_fastapi_sdk/utils/storage_backends.py
def __init__(
    self,
    client: AsyncMinIOClient,
    *,
    bucket: str | None = None,
) -> None:
    """Initialize.

    Args:
        client (AsyncMinIOClient): A configured MinIO client.
        bucket (str | None): Target bucket. ``None`` uses the
            client's ``default_bucket``.
    """
    self.client: AsyncMinIOClient = client
    self.bucket: str | None = bucket

write_stream async

write_stream(
    key: str,
    chunks: AsyncIterator[bytes],
    *,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
    max_size_bytes: int | None = None,
    validator: ContentValidator | None = None,
) -> UploadResult

Buffer chunks, validate, then put_object.

S3 needs the content length upfront, so the stream is materialized in memory before the upload starts. For very large objects, prefer presigned_put_url and let the client upload directly.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def write_stream(
    self,
    key: str,
    chunks: AsyncIterator[bytes],
    *,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
    max_size_bytes: int | None = None,
    validator: ContentValidator | None = None,
) -> UploadResult:
    """Buffer ``chunks``, validate, then ``put_object``.

    S3 needs the content length upfront, so the stream is
    materialized in memory before the upload starts. For
    very large objects, prefer ``presigned_put_url`` and let
    the client upload directly.
    """
    from tempest_fastapi_sdk.exceptions.upload import (
        FileTooLargeException,
        InvalidFileTypeException,
    )

    buffer = bytearray()
    first = True
    async for chunk in chunks:
        if first:
            first = False
            if validator is not None and not validator(chunk):
                raise InvalidFileTypeException(
                    details={"reason": "validator rejected first chunk"},
                )
        buffer.extend(chunk)
        if max_size_bytes is not None and len(buffer) > max_size_bytes:
            raise FileTooLargeException(
                details={"max_size_bytes": max_size_bytes},
            )

    await self.client.put_object(
        key,
        bytes(buffer),
        bucket=self.bucket,
        content_type=content_type,
        metadata=metadata,
    )
    url = await self.client.presigned_get_url(key, bucket=self.bucket)
    return UploadResult(
        key=key,
        size=len(buffer),
        path=None,
        url=url,
    )

delete async

delete(key: str) -> bool

Delete the object — always returns True (S3 idempotent).

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def delete(self, key: str) -> bool:
    """Delete the object — always returns ``True`` (S3 idempotent)."""
    await self.client.remove_object(key, bucket=self.bucket)
    return True

exists async

exists(key: str) -> bool

Stat-probe the object.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def exists(self, key: str) -> bool:
    """Stat-probe the object."""
    try:
        await self.client.stat_object(key, bucket=self.bucket)
        return True
    except Exception:
        return False

presigned_url async

presigned_url(key: str, *, expires: timedelta = timedelta(hours=1)) -> str | None

Return a presigned GET URL.

Source code in tempest_fastapi_sdk/utils/storage_backends.py
async def presigned_url(
    self,
    key: str,
    *,
    expires: timedelta = timedelta(hours=1),
) -> str | None:
    """Return a presigned GET URL."""
    return await self.client.presigned_get_url(
        key,
        bucket=self.bucket,
        expires=expires,
    )

HTTPClient

HTTPClient(
    *,
    base_url: str = "",
    timeout: float = 10.0,
    retry_policy: RetryPolicy | None = None,
    failure_threshold: int = 5,
    recovery_seconds: float = 30.0,
    default_headers: Mapping[str, str] | None = None,
    verify_tls: bool = True,
    propagate_request_id: bool = True,
)

Async HTTP client with retries, circuit-breaker and request-id propagation.

Example:

>>> client = HTTPClient(base_url="https://api.example.com")
>>> async with client:
...     response = await client.get("/users/me")
...     payload: dict[str, Any] = response.json()

The client is safe to share across requests on the same event loop — internally each call uses the shared :class:httpx.AsyncClient connection pool.

Initialize.

Parameters:

Name Type Description Default
base_url str

Prepended to relative paths. Use empty string to require absolute URLs at the call site.

''
timeout float

Per-request timeout in seconds. Overridable per call.

10.0
retry_policy RetryPolicy | None

Retry configuration. None uses the defaults (3 attempts, ~0.5/½s backoff).

None
failure_threshold int

Consecutive 5xx/network errors that trip the circuit per host. 0 disables the breaker.

5
recovery_seconds float

Seconds the breaker stays open before allowing one half-open probe.

30.0
default_headers Mapping[str, str] | None

Headers attached to every request (e.g. Authorization).

None
verify_tls bool

Whether to verify TLS certificates. Default True — flip only for internal mTLS or dev with self-signed certs.

True
propagate_request_id bool

When True (default), attach X-Request-ID from the current contextvar to outbound requests.

True

Raises:

Type Description
ImportError

When the [http] extra is missing.

Source code in tempest_fastapi_sdk/utils/http_client.py
def __init__(
    self,
    *,
    base_url: str = "",
    timeout: float = 10.0,
    retry_policy: RetryPolicy | None = None,
    failure_threshold: int = 5,
    recovery_seconds: float = 30.0,
    default_headers: Mapping[str, str] | None = None,
    verify_tls: bool = True,
    propagate_request_id: bool = True,
) -> None:
    """Initialize.

    Args:
        base_url (str): Prepended to relative paths. Use empty
            string to require absolute URLs at the call site.
        timeout (float): Per-request timeout in seconds.
            Overridable per call.
        retry_policy (RetryPolicy | None): Retry configuration.
            ``None`` uses the defaults (3 attempts, ~0.5/1/2s
            backoff).
        failure_threshold (int): Consecutive 5xx/network errors
            that trip the circuit per host. ``0`` disables the
            breaker.
        recovery_seconds (float): Seconds the breaker stays
            open before allowing one half-open probe.
        default_headers (Mapping[str, str] | None): Headers
            attached to every request (e.g. ``Authorization``).
        verify_tls (bool): Whether to verify TLS certificates.
            Default ``True`` — flip only for internal mTLS or
            dev with self-signed certs.
        propagate_request_id (bool): When ``True`` (default),
            attach ``X-Request-ID`` from the current
            contextvar to outbound requests.

    Raises:
        ImportError: When the ``[http]`` extra is missing.
    """
    if _httpx_mod is None:
        raise ImportError(
            "HTTPClient requires the [http] extra. "
            "Install with `pip install tempest-fastapi-sdk[http]`."
        )
    self.base_url: str = base_url
    self.timeout: float = timeout
    self.retry_policy: RetryPolicy = retry_policy or RetryPolicy()
    self.failure_threshold: int = failure_threshold
    self.recovery_seconds: float = recovery_seconds
    self.propagate_request_id: bool = propagate_request_id
    self._client: httpx.AsyncClient = _httpx_mod.AsyncClient(
        base_url=base_url,
        timeout=_httpx_mod.Timeout(timeout, connect=5.0),
        headers=dict(default_headers or {}),
        verify=verify_tls,
    )
    self._breakers: dict[str, _BreakerState] = {}
    self._lock: asyncio.Lock = asyncio.Lock()

aclose async

aclose() -> None

Close the underlying connection pool. Safe to call twice.

Source code in tempest_fastapi_sdk/utils/http_client.py
async def aclose(self) -> None:
    """Close the underlying connection pool. Safe to call twice."""
    await self._client.aclose()

request async

request(
    method: str,
    url: str,
    *,
    params: Mapping[str, Any] | None = None,
    json: Any = None,
    data: Any = None,
    headers: Mapping[str, str] | None = None,
    timeout: float | None = None,
) -> Response

Perform an HTTP request with retries and circuit-breaker.

Parameters:

Name Type Description Default
method str

HTTP verb ("GET", "POST", ...).

required
url str

Absolute URL or path relative to base_url.

required
params Mapping[str, Any] | None

Query-string params.

None
json Any

JSON-serializable body. Mutually exclusive with data.

None
data Any

Form body. Mutually exclusive with json.

None
headers Mapping[str, str] | None

Per-request headers merged on top of default_headers + propagated X-Request-ID.

None
timeout float | None

Override for this call.

None

Returns:

Type Description
Response

httpx.Response: The successful response. Caller checks

Response

response.status_code for 4xx outcomes (those are

Response

not retried).

Raises:

Type Description
CircuitOpenError

When the per-host breaker is open.

HTTPError

When all attempts failed (last exception is re-raised).

Source code in tempest_fastapi_sdk/utils/http_client.py
async def request(
    self,
    method: str,
    url: str,
    *,
    params: Mapping[str, Any] | None = None,
    json: Any = None,
    data: Any = None,
    headers: Mapping[str, str] | None = None,
    timeout: float | None = None,
) -> httpx.Response:
    """Perform an HTTP request with retries and circuit-breaker.

    Args:
        method (str): HTTP verb (``"GET"``, ``"POST"``, ...).
        url (str): Absolute URL or path relative to ``base_url``.
        params (Mapping[str, Any] | None): Query-string params.
        json (Any): JSON-serializable body. Mutually exclusive
            with ``data``.
        data (Any): Form body. Mutually exclusive with ``json``.
        headers (Mapping[str, str] | None): Per-request headers
            merged on top of ``default_headers`` + propagated
            ``X-Request-ID``.
        timeout (float | None): Override for this call.

    Returns:
        httpx.Response: The successful response. Caller checks
        ``response.status_code`` for 4xx outcomes (those are
        **not** retried).

    Raises:
        CircuitOpenError: When the per-host breaker is open.
        httpx.HTTPError: When all attempts failed (last
            exception is re-raised).
    """
    assert _httpx_mod is not None, "guarded by __init__"
    host = self._host_of(url)
    await self._breaker_check(host)

    merged_headers = self._build_headers(headers)
    last_exc: BaseException | None = None
    last_response: httpx.Response | None = None
    for attempt in range(1, self.retry_policy.max_attempts + 1):
        try:
            response = await self._client.request(
                method,
                url,
                params=params,
                json=json,
                data=data,
                headers=merged_headers,
                timeout=timeout if timeout is not None else self.timeout,
            )
        except (_httpx_mod.ConnectError, _httpx_mod.ReadTimeout) as exc:
            last_exc = exc
            if attempt == self.retry_policy.max_attempts:
                await self._breaker_record(host, failed=True)
                raise
            await asyncio.sleep(self.retry_policy.sleep_for(attempt))
            continue

        if response.status_code in self.retry_policy.retry_statuses:
            last_response = response
            if attempt == self.retry_policy.max_attempts:
                await self._breaker_record(host, failed=True)
                return response
            await asyncio.sleep(self.retry_policy.sleep_for(attempt))
            continue

        await self._breaker_record(host, failed=False)
        return response

    # Should be unreachable — the loop either returns or raises.
    if last_response is not None:
        return last_response
    assert last_exc is not None
    raise last_exc  # pragma: no cover

get async

get(url: str, **kwargs: Any) -> Response

Shortcut for request("GET", url, ...).

Source code in tempest_fastapi_sdk/utils/http_client.py
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
    """Shortcut for ``request("GET", url, ...)``."""
    return await self.request("GET", url, **kwargs)

post async

post(url: str, **kwargs: Any) -> Response

Shortcut for request("POST", url, ...).

Source code in tempest_fastapi_sdk/utils/http_client.py
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
    """Shortcut for ``request("POST", url, ...)``."""
    return await self.request("POST", url, **kwargs)

put async

put(url: str, **kwargs: Any) -> Response

Shortcut for request("PUT", url, ...).

Source code in tempest_fastapi_sdk/utils/http_client.py
async def put(self, url: str, **kwargs: Any) -> httpx.Response:
    """Shortcut for ``request("PUT", url, ...)``."""
    return await self.request("PUT", url, **kwargs)

patch async

patch(url: str, **kwargs: Any) -> Response

Shortcut for request("PATCH", url, ...).

Source code in tempest_fastapi_sdk/utils/http_client.py
async def patch(self, url: str, **kwargs: Any) -> httpx.Response:
    """Shortcut for ``request("PATCH", url, ...)``."""
    return await self.request("PATCH", url, **kwargs)

delete async

delete(url: str, **kwargs: Any) -> Response

Shortcut for request("DELETE", url, ...).

Source code in tempest_fastapi_sdk/utils/http_client.py
async def delete(self, url: str, **kwargs: Any) -> httpx.Response:
    """Shortcut for ``request("DELETE", url, ...)``."""
    return await self.request("DELETE", url, **kwargs)

RetryPolicy dataclass

RetryPolicy(
    max_attempts: int = 3,
    backoff_initial_seconds: float = 0.5,
    backoff_max_seconds: float = 8.0,
    retry_statuses: frozenset[int] = (lambda: frozenset({429, 500, 502, 503, 504}))(),
)

Bounded exponential backoff for retried requests.

The first retry sleeps for backoff_initial_seconds; each subsequent retry doubles the wait, capped at backoff_max_seconds. Total retries are bounded by max_attempts (the first try counts).

Attributes:

Name Type Description
max_attempts int

Total tries including the first. 1 disables retries.

backoff_initial_seconds float

Sleep before the second attempt.

backoff_max_seconds float

Hard cap per sleep.

retry_statuses frozenset[int]

HTTP status codes worth retrying. Defaults to common 5xx; 429 is included because it usually means "back off and try again".

sleep_for

sleep_for(attempt: int) -> float

Compute the sleep between attempt n and attempt n+1.

Source code in tempest_fastapi_sdk/utils/http_client.py
def sleep_for(self, attempt: int) -> float:
    """Compute the sleep between attempt ``n`` and attempt ``n+1``."""
    wait: float = self.backoff_initial_seconds * (2 ** max(0, attempt - 1))
    return min(wait, self.backoff_max_seconds)

CircuitOpenError

CircuitOpenError(host: str)

Bases: Exception

Raised when the circuit-breaker rejects a call.

Carries the host that tripped the breaker so callers can branch on it (e.g. fall back to a cache or a queue).

Initialize.

Parameters:

Name Type Description Default
host str

The host whose breaker is open.

required
Source code in tempest_fastapi_sdk/utils/http_client.py
def __init__(self, host: str) -> None:
    """Initialize.

    Args:
        host (str): The host whose breaker is open.
    """
    super().__init__(f"circuit open for host {host!r}")
    self.host: str = host

GoogleOAuthClient

GoogleOAuthClient(
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
)

Bases: _BaseOAuthClient

Google identity client (OIDC-compatible).

Default scopes: openid email profile.

Source code in tempest_fastapi_sdk/api/oauth.py
def __init__(
    self,
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
) -> None:
    """Initialize.

    Args:
        client_id (str): App client id issued by the provider.
        client_secret (str): App client secret.
        redirect_uri (str): Callback URL registered with the
            provider; must match exactly.
        scopes (list[str] | None): Scopes to request. Provider
            subclasses ship sensible defaults.
        http_client (HTTPClient | None): Shared client to
            reuse. ``None`` builds a dedicated one with sane
            defaults.
    """
    self.client_id: str = client_id
    self.client_secret: str = client_secret
    self.redirect_uri: str = redirect_uri
    self.scopes: list[str] = scopes or self._default_scopes()
    self._http: HTTPClient = http_client or HTTPClient(
        timeout=10.0,
        failure_threshold=0,
    )
    self._owns_http: bool = http_client is None

authorize_url property

authorize_url: str

Google's authorize endpoint.

token_url property

token_url: str

Google's token endpoint.

userinfo_url property

userinfo_url: str | None

OIDC-flavored userinfo endpoint.

GitHubOAuthClient

GitHubOAuthClient(
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
)

Bases: _BaseOAuthClient

GitHub OAuth client.

GitHub doesn't issue an id_token — the user identity comes from GET /user. Default scopes: read:user user:email.

Source code in tempest_fastapi_sdk/api/oauth.py
def __init__(
    self,
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
) -> None:
    """Initialize.

    Args:
        client_id (str): App client id issued by the provider.
        client_secret (str): App client secret.
        redirect_uri (str): Callback URL registered with the
            provider; must match exactly.
        scopes (list[str] | None): Scopes to request. Provider
            subclasses ship sensible defaults.
        http_client (HTTPClient | None): Shared client to
            reuse. ``None`` builds a dedicated one with sane
            defaults.
    """
    self.client_id: str = client_id
    self.client_secret: str = client_secret
    self.redirect_uri: str = redirect_uri
    self.scopes: list[str] = scopes or self._default_scopes()
    self._http: HTTPClient = http_client or HTTPClient(
        timeout=10.0,
        failure_threshold=0,
    )
    self._owns_http: bool = http_client is None

authorize_url property

authorize_url: str

GitHub's authorize endpoint.

token_url property

token_url: str

GitHub's token endpoint.

userinfo_url property

userinfo_url: str | None

GitHub's user-info endpoint.

OIDCProvider

OIDCProvider(
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    authorize_url: str,
    token_url: str,
    userinfo_url: str | None = None,
    provider_name: str = "oidc",
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
)

Bases: _BaseOAuthClient

Generic OIDC provider — works with any conformant IdP.

Pass the authorize / token / userinfo endpoints explicitly, or fetch them once at boot from the IdP's discovery document at ${issuer}/.well-known/openid-configuration and pass the URLs in. Default scopes: openid email profile.

Initialize.

Parameters:

Name Type Description Default
client_id str

App client id at the IdP.

required
client_secret str

App client secret.

required
redirect_uri str

Registered callback URL.

required
authorize_url str

IdP's authorize endpoint.

required
token_url str

IdP's token endpoint.

required
userinfo_url str | None

IdP's userinfo endpoint. None requires you to override :meth:_parse_user to read claims from the id_token.

None
provider_name str

Key embedded in :attr:OAuthUser.provider (e.g. "oidc:auth0").

'oidc'
scopes list[str] | None

Scopes to request.

None
http_client HTTPClient | None

Shared client.

None
Source code in tempest_fastapi_sdk/api/oauth.py
def __init__(
    self,
    *,
    client_id: str,
    client_secret: str,
    redirect_uri: str,
    authorize_url: str,
    token_url: str,
    userinfo_url: str | None = None,
    provider_name: str = "oidc",
    scopes: list[str] | None = None,
    http_client: HTTPClient | None = None,
) -> None:
    """Initialize.

    Args:
        client_id (str): App client id at the IdP.
        client_secret (str): App client secret.
        redirect_uri (str): Registered callback URL.
        authorize_url (str): IdP's authorize endpoint.
        token_url (str): IdP's token endpoint.
        userinfo_url (str | None): IdP's userinfo endpoint.
            ``None`` requires you to override
            :meth:`_parse_user` to read claims from the
            ``id_token``.
        provider_name (str): Key embedded in
            :attr:`OAuthUser.provider` (e.g. ``"oidc:auth0"``).
        scopes (list[str] | None): Scopes to request.
        http_client (HTTPClient | None): Shared client.
    """
    self._authorize_url: str = authorize_url
    self._token_url: str = token_url
    self._userinfo_url: str | None = userinfo_url
    self.provider_name = provider_name
    super().__init__(
        client_id=client_id,
        client_secret=client_secret,
        redirect_uri=redirect_uri,
        scopes=scopes,
        http_client=http_client,
    )

OAuthUser dataclass

OAuthUser(
    provider: str,
    subject: str,
    email: str | None = None,
    name: str | None = None,
    picture: str | None = None,
    raw: dict[str, Any] = dict(),
)

Normalized user identity returned by every provider.

Different IdPs use different field names (sub vs id, picture vs avatar_url, name vs login). This dataclass is the single shape the rest of the application sees.

Attributes:

Name Type Description
provider str

Provider key ("google", "github", "oidc:auth0" …). Useful when multiple providers feed the same user table.

subject str

Stable per-provider user id. Combine with provider for a globally-unique key.

email str | None

Verified email when the provider returned one. Some IdPs gate this behind extra scopes.

name str | None

Human-readable display name.

picture str | None

Avatar / profile picture URL.

raw dict[str, Any]

Full provider payload for advanced cases (custom claims, role mappings).

OAuthTokens dataclass

OAuthTokens(
    access_token: str,
    token_type: str,
    refresh_token: str | None = None,
    id_token: str | None = None,
    expires_in: int | None = None,
    scope: str | None = None,
    raw: dict[str, Any] = dict(),
)

Tokens returned by the IdP after the authorization-code exchange.

Attributes:

Name Type Description
access_token str

Bearer token to call provider APIs.

token_type str

Usually "Bearer".

refresh_token str | None

Refresh token when offline access was requested.

id_token str | None

OIDC id token (JWT). Present on OIDC flows, absent on plain OAuth2.

expires_in int | None

Lifetime of access_token in seconds.

scope str | None

Space-separated scopes granted.

raw dict[str, Any]

Full token-endpoint response.

apply_cors

apply_cors(
    app: FastAPI,
    settings: CORSSettings | None = None,
    *,
    origins: list[str] | None = None,
    allow_credentials: bool | None = None,
    allow_methods: list[str] | None = None,
    allow_headers: list[str] | None = None,
    expose_headers: list[str] | None = None,
    max_age: int | None = None,
) -> None

Attach CORSMiddleware to app using SDK conventions.

All overrides are optional; when provided they take precedence over the matching field on settings. When neither is provided, a permissive default suitable for local development is used (origins=["*"], no credentials).

Parameters:

Name Type Description Default
app FastAPI

The application to mutate.

required
settings CORSSettings | None

Source of the defaults. When None, a fresh :class:CORSSettings instance is built.

None
origins list[str] | None

Override for :attr:CORSSettings.CORS_ORIGINS.

None
allow_credentials bool | None

Override.

None
allow_methods list[str] | None

Override.

None
allow_headers list[str] | None

Override.

None
expose_headers list[str] | None

Override.

None
max_age int | None

Override.

None
Source code in tempest_fastapi_sdk/api/middlewares/cors.py
def apply_cors(
    app: FastAPI,
    settings: CORSSettings | None = None,
    *,
    origins: list[str] | None = None,
    allow_credentials: bool | None = None,
    allow_methods: list[str] | None = None,
    allow_headers: list[str] | None = None,
    expose_headers: list[str] | None = None,
    max_age: int | None = None,
) -> None:
    """Attach ``CORSMiddleware`` to ``app`` using SDK conventions.

    All overrides are optional; when provided they take precedence
    over the matching field on ``settings``. When neither is provided,
    a permissive default suitable for local development is used
    (``origins=["*"]``, no credentials).

    Args:
        app (FastAPI): The application to mutate.
        settings (CORSSettings | None): Source of the defaults. When
            ``None``, a fresh :class:`CORSSettings` instance is built.
        origins (list[str] | None): Override for
            :attr:`CORSSettings.CORS_ORIGINS`.
        allow_credentials (bool | None): Override.
        allow_methods (list[str] | None): Override.
        allow_headers (list[str] | None): Override.
        expose_headers (list[str] | None): Override.
        max_age (int | None): Override.
    """
    cfg = settings or CORSSettings()
    app.add_middleware(
        CORSMiddleware,
        allow_origins=origins if origins is not None else cfg.CORS_ORIGINS,
        allow_credentials=(
            allow_credentials
            if allow_credentials is not None
            else cfg.CORS_ALLOW_CREDENTIALS
        ),
        allow_methods=(
            allow_methods if allow_methods is not None else cfg.CORS_ALLOW_METHODS
        ),
        allow_headers=(
            allow_headers if allow_headers is not None else cfg.CORS_ALLOW_HEADERS
        ),
        expose_headers=(
            expose_headers if expose_headers is not None else cfg.CORS_EXPOSE_HEADERS
        ),
        max_age=max_age if max_age is not None else cfg.CORS_MAX_AGE,
    )

make_health_router

make_health_router(
    *,
    db: AsyncDatabaseManager | None = None,
    checks: dict[str, HealthCheck] | None = None,
    prefix: str = "/health",
    tag: str = "health",
    version: str | None = None,
    expose_checks: bool = True,
) -> APIRouter

Build the canonical /health router.

Two endpoints are mounted:

  • GET <prefix>/liveness — always returns {"status": "ok"} so orchestrators can confirm the process is up. Should not depend on any external resource (Kubernetes treats failed liveness probes as "restart the pod"). This endpoint takes precedence over readiness for that reason.
  • GET <prefix>/readiness — runs every configured check and returns 200 only when all pass. Returns 503 when at least one fails.

Parameters:

Name Type Description Default
db AsyncDatabaseManager | None

When provided, a database check is registered automatically using :meth:AsyncDatabaseManager.health_check.

None
checks dict[str, HealthCheck] | None

Extra readiness checks keyed by name (e.g. "redis", "rabbitmq").

None
prefix str

The URL prefix for the router. Defaults to "/health" — keep it at the application root, not under /api.

'/health'
tag str

OpenAPI tag applied to both endpoints.

'health'
version str | None

When provided, attached to the readiness payload as version.

None
expose_checks bool

Whether to surface the per-dependency breakdown in the readiness payload. Defaults to True for development ergonomics; set False in production so unauthenticated probes don't reveal which backends (database, Redis, RabbitMQ, etc.) the service depends on.

True

Returns:

Name Type Description
APIRouter APIRouter

A router ready to include_router(...) on the

APIRouter

FastAPI app.

Source code in tempest_fastapi_sdk/api/routers/health.py
def make_health_router(
    *,
    db: AsyncDatabaseManager | None = None,
    checks: dict[str, HealthCheck] | None = None,
    prefix: str = "/health",
    tag: str = "health",
    version: str | None = None,
    expose_checks: bool = True,
) -> APIRouter:
    """Build the canonical ``/health`` router.

    Two endpoints are mounted:

    * ``GET <prefix>/liveness`` — always returns ``{"status": "ok"}``
      so orchestrators can confirm the process is up. Should not
      depend on any external resource (Kubernetes treats failed
      liveness probes as "restart the pod"). This endpoint takes
      precedence over readiness for that reason.
    * ``GET <prefix>/readiness`` — runs every configured check and
      returns ``200`` only when all pass. Returns ``503`` when at
      least one fails.

    Args:
        db (AsyncDatabaseManager | None): When provided, a
            ``database`` check is registered automatically using
            :meth:`AsyncDatabaseManager.health_check`.
        checks (dict[str, HealthCheck] | None): Extra readiness
            checks keyed by name (e.g. ``"redis"``, ``"rabbitmq"``).
        prefix (str): The URL prefix for the router. Defaults to
            ``"/health"`` — keep it at the application root, not
            under ``/api``.
        tag (str): OpenAPI tag applied to both endpoints.
        version (str | None): When provided, attached to the
            readiness payload as ``version``.
        expose_checks (bool): Whether to surface the per-dependency
            breakdown in the readiness payload. Defaults to ``True``
            for development ergonomics; set ``False`` in production
            so unauthenticated probes don't reveal which backends
            (database, Redis, RabbitMQ, etc.) the service depends on.

    Returns:
        APIRouter: A router ready to ``include_router(...)`` on the
        FastAPI app.
    """
    router = APIRouter(prefix=prefix, tags=[tag])
    extra_checks: dict[str, HealthCheck] = dict(checks or {})

    @router.get("/liveness", summary="Liveness probe")
    async def liveness() -> dict[str, str]:
        """Return ``{"status": "ok"}`` if the process is alive."""
        return {"status": "ok"}

    @router.get(
        "/readiness",
        summary="Readiness probe",
        responses={
            status.HTTP_503_SERVICE_UNAVAILABLE: {
                "description": "At least one dependency is not ready.",
            },
        },
    )
    async def readiness() -> JSONResponse:
        """Return per-dependency status and a 503 when any check fails."""
        results: dict[str, bool] = {}
        if db is not None:
            try:
                results["database"] = await db.health_check()
            except Exception as exc:
                logger.warning("Health check 'database' raised: %s", exc)
                results["database"] = False
        for name, check in extra_checks.items():
            try:
                results[name] = await check()
            except Exception as exc:
                logger.warning("Health check %r raised: %s", name, exc)
                results[name] = False

        overall = all(results.values()) if results else True
        payload: dict[str, Any] = {
            "status": "ready" if overall else "not_ready",
        }
        if expose_checks:
            payload["checks"] = results
        if version is not None:
            payload["version"] = version
        return JSONResponse(
            payload,
            status_code=(
                status.HTTP_200_OK if overall else status.HTTP_503_SERVICE_UNAVAILABLE
            ),
        )

    return router

make_logs_router

make_logs_router(
    *,
    log_dir: str | Path = "logs",
    token_secret: str = "",
    prefix: str = "/logs",
    tag: str = "logs",
    header_name: str = "X-Token",
    default_page_size: int = 20,
    max_page_size: int = 200,
) -> APIRouter

Build a router that serves the on-disk JSON logs, paginated.

Mounts GET <prefix> which reads the files produced by :func:tempest_fastapi_sdk.configure_logging (called with log_dir=...), filters them, and returns a :class:BasePaginationSchema of :class:LogEntrySchema. Newest records come first.

The endpoint is gated by a shared-secret X-Token header via :func:make_token_dependency. An empty token_secret disables the check (development only) — never ship log access unauthenticated in production, the payload exposes tracebacks and request metadata.

Parameters:

Name Type Description Default
log_dir str | Path

Directory holding the log files. Must match the log_dir passed to configure_logging. Defaults to "logs".

'logs'
token_secret str

Shared secret for the X-Token header. Empty disables auth (dev only).

''
prefix str

URL prefix for the router. Defaults to "/logs" — mount it at the application root, not under /api.

'/logs'
tag str

OpenAPI tag applied to the endpoint.

'logs'
header_name str

Auth header name. Defaults to "X-Token".

'X-Token'
default_page_size int

Page size when the caller omits it.

20
max_page_size int

Upper bound enforced on page_size.

200

Returns:

Name Type Description
APIRouter APIRouter

A router ready to include_router(...) on the app.

Source code in tempest_fastapi_sdk/api/routers/logs.py
def make_logs_router(
    *,
    log_dir: str | Path = "logs",
    token_secret: str = "",
    prefix: str = "/logs",
    tag: str = "logs",
    header_name: str = "X-Token",
    default_page_size: int = 20,
    max_page_size: int = 200,
) -> APIRouter:
    """Build a router that serves the on-disk JSON logs, paginated.

    Mounts ``GET <prefix>`` which reads the files produced by
    :func:`tempest_fastapi_sdk.configure_logging` (called with
    ``log_dir=...``), filters them, and returns a
    :class:`BasePaginationSchema` of :class:`LogEntrySchema`. Newest
    records come first.

    The endpoint is gated by a shared-secret ``X-Token`` header via
    :func:`make_token_dependency`. An empty ``token_secret`` disables
    the check (development only) — never ship log access unauthenticated
    in production, the payload exposes tracebacks and request metadata.

    Args:
        log_dir (str | Path): Directory holding the log files. Must
            match the ``log_dir`` passed to ``configure_logging``.
            Defaults to ``"logs"``.
        token_secret (str): Shared secret for the ``X-Token`` header.
            Empty disables auth (dev only).
        prefix (str): URL prefix for the router. Defaults to
            ``"/logs"`` — mount it at the application root, not under
            ``/api``.
        tag (str): OpenAPI tag applied to the endpoint.
        header_name (str): Auth header name. Defaults to ``"X-Token"``.
        default_page_size (int): Page size when the caller omits it.
        max_page_size (int): Upper bound enforced on ``page_size``.

    Returns:
        APIRouter: A router ready to ``include_router(...)`` on the app.
    """
    router = APIRouter(prefix=prefix, tags=[tag])
    base_dir = Path(log_dir)
    require_token = make_token_dependency(token_secret, header_name=header_name)

    @router.get(
        "",
        summary="Read structured application logs",
        response_model=BasePaginationSchema[LogEntrySchema],
        dependencies=[Depends(require_token)],
    )
    async def read_logs(
        source: LogSource = Query(
            default="all",
            description=(
                "Which log file(s) to read. 'all' merges every level; "
                "'500' returns only isolated unhandled-500 records."
            ),
        ),
        q: str | None = Query(
            default=None,
            description="Case-insensitive substring match on the message.",
        ),
        start: datetime | None = Query(
            default=None,
            description="Only records at or after this ISO-8601 instant.",
        ),
        end: datetime | None = Query(
            default=None,
            description="Only records at or before this ISO-8601 instant.",
        ),
        page: int = Query(default=1, ge=1, description="Page number (1-indexed)."),
        page_size: int = Query(
            default=default_page_size,
            ge=1,
            description="Items per page.",
        ),
    ) -> BasePaginationSchema[LogEntrySchema]:
        """Return a paginated, filtered page of log records (newest first).

        Args:
            source (LogSource): Which file(s) to read.
            q (str | None): Case-insensitive message substring filter.
            start (datetime | None): Lower bound on the record timestamp.
            end (datetime | None): Upper bound on the record timestamp.
            page (int): The 1-indexed page number.
            page_size (int): Items per page (capped at ``max_page_size``).

        Returns:
            BasePaginationSchema[LogEntrySchema]: The page of records
            with pagination metadata.
        """
        size = min(page_size, max_page_size)
        files = _resolve_files(base_dir, source)
        entries = await run_in_threadpool(_read_entries, files)

        needle = q.lower() if q else None
        filtered: list[dict[str, Any]] = []
        for entry in entries:
            if needle is not None:
                message = str(entry.get("message", "")).lower()
                if needle not in message:
                    continue
            if start is not None or end is not None:
                moment = _parse_timestamp(str(entry.get("timestamp", "")))
                if moment is None:
                    continue
                if start is not None and moment < start:
                    continue
                if end is not None and moment > end:
                    continue
            filtered.append(entry)

        filtered.sort(key=lambda item: str(item.get("timestamp", "")), reverse=True)

        total = len(filtered)
        pages = (total + size - 1) // size if total else 0
        offset = (page - 1) * size
        window = filtered[offset : offset + size]

        return BasePaginationSchema[LogEntrySchema](
            items=[LogEntrySchema.model_validate(item) for item in window],
            total=total,
            page=page,
            page_size=size,
            pages=pages,
        )

    return router

PrometheusMiddleware

PrometheusMiddleware(
    app: ASGIApp,
    *,
    registry: CollectorRegistry,
    latency_buckets: tuple[float, ...] = DEFAULT_LATENCY_BUCKETS,
)

Bases: BaseHTTPMiddleware

ASGI middleware tracking HTTP requests on three core metrics.

Registered series:

  • http_requests_total{method, path, status} (Counter) — every request counts here once the response status is known.
  • http_request_duration_seconds{method, path} (Histogram) — end-to-end latency in seconds.
  • http_requests_in_progress{method} (Gauge) — live inflight count, decremented in a finally so dropped connections never leave stale gauges.

The path label uses the route template (e.g. /orders/{order_id}) when the request hit a FastAPI route, not the raw URL — that keeps the cardinality bounded.

Initialize the middleware.

Parameters:

Name Type Description Default
app ASGIApp

The wrapped ASGI app.

required
registry CollectorRegistry

Shared registry. Reuse the same instance for make_prometheus_router so the /metrics endpoint scrapes these series.

required
latency_buckets tuple[float, ...]

Histogram bucket upper bounds in seconds.

DEFAULT_LATENCY_BUCKETS

Raises:

Type Description
ImportError

When the [prometheus] extra is missing.

Source code in tempest_fastapi_sdk/api/routers/metrics.py
def __init__(
    self,
    app: ASGIApp,
    *,
    registry: CollectorRegistry,
    latency_buckets: tuple[float, ...] = DEFAULT_LATENCY_BUCKETS,
) -> None:
    """Initialize the middleware.

    Args:
        app (ASGIApp): The wrapped ASGI app.
        registry (CollectorRegistry): Shared registry. Reuse the
            same instance for ``make_prometheus_router`` so the
            ``/metrics`` endpoint scrapes these series.
        latency_buckets (tuple[float, ...]): Histogram bucket
            upper bounds in seconds.

    Raises:
        ImportError: When the ``[prometheus]`` extra is missing.
    """
    _require_prometheus()
    super().__init__(app)
    self.requests_total: Counter = Counter(
        "http_requests_total",
        "HTTP requests by method, route template, and response status.",
        labelnames=("method", "path", "status"),
        registry=registry,
    )
    self.request_duration: Histogram = Histogram(
        "http_request_duration_seconds",
        "HTTP request latency by method and route template (seconds).",
        labelnames=("method", "path"),
        buckets=latency_buckets,
        registry=registry,
    )
    self.in_progress: Gauge = Gauge(
        "http_requests_in_progress",
        "HTTP requests currently being handled.",
        labelnames=("method",),
        registry=registry,
    )

dispatch async

dispatch(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response

Wrap the request with counters, gauge, and latency timer.

Source code in tempest_fastapi_sdk/api/routers/metrics.py
async def dispatch(
    self,
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    """Wrap the request with counters, gauge, and latency timer."""
    method = request.method
    self.in_progress.labels(method=method).inc()
    start = time.perf_counter()
    status_code = 500
    try:
        response = await call_next(request)
        status_code = response.status_code
        return response
    finally:
        elapsed = time.perf_counter() - start
        path = self._route_template(request)
        self.requests_total.labels(
            method=method,
            path=path,
            status=str(status_code),
        ).inc()
        self.request_duration.labels(method=method, path=path).observe(elapsed)
        self.in_progress.labels(method=method).dec()

make_prometheus_router

make_prometheus_router(
    *,
    registry: CollectorRegistry,
    path: str = "/metrics",
    dependencies: list[Callable[..., Any]] | None = None,
) -> APIRouter

Build the GET /metrics router scraping registry.

Parameters:

Name Type Description Default
registry CollectorRegistry

The same registry passed to :class:PrometheusMiddleware and any custom metric.

required
path str

Endpoint path. Defaults to /metrics.

'/metrics'
dependencies list | None

FastAPI dependencies to attach — typically [Depends(require_x_token)] so the endpoint isn't world-readable.

None

Returns:

Name Type Description
APIRouter APIRouter

Mount with app.include_router(router).

Raises:

Type Description
ImportError

When the [prometheus] extra is missing.

Source code in tempest_fastapi_sdk/api/routers/metrics.py
def make_prometheus_router(
    *,
    registry: CollectorRegistry,
    path: str = "/metrics",
    dependencies: list[Callable[..., Any]] | None = None,
) -> APIRouter:
    """Build the ``GET /metrics`` router scraping ``registry``.

    Args:
        registry (CollectorRegistry): The same registry passed to
            :class:`PrometheusMiddleware` and any custom metric.
        path (str): Endpoint path. Defaults to ``/metrics``.
        dependencies (list | None): FastAPI dependencies to attach
            — typically ``[Depends(require_x_token)]`` so the
            endpoint isn't world-readable.

    Returns:
        APIRouter: Mount with ``app.include_router(router)``.

    Raises:
        ImportError: When the ``[prometheus]`` extra is missing.
    """
    _require_prometheus()
    router = APIRouter()

    @router.get(
        path,
        dependencies=[Depends(d) for d in (dependencies or [])],
        include_in_schema=False,
    )
    async def metrics() -> Response:
        """Render the registry in Prometheus exposition format."""
        return Response(
            content=generate_latest(registry),
            media_type=CONTENT_TYPE_LATEST,
        )

    return router

make_prometheus_registry

make_prometheus_registry() -> CollectorRegistry

Build a fresh :class:CollectorRegistry.

Use this once at app boot, share the registry across the middleware and any custom metric you register, then pass it to :func:make_prometheus_router.

Returns:

Name Type Description
CollectorRegistry CollectorRegistry

An empty registry, decoupled from the

CollectorRegistry

prometheus_client default singleton so tests don't bleed

CollectorRegistry

metrics between runs.

Raises:

Type Description
ImportError

When the [prometheus] extra is missing.

Source code in tempest_fastapi_sdk/api/routers/metrics.py
def make_prometheus_registry() -> CollectorRegistry:
    """Build a fresh :class:`CollectorRegistry`.

    Use this once at app boot, share the registry across the
    middleware and any custom metric you register, then pass it to
    :func:`make_prometheus_router`.

    Returns:
        CollectorRegistry: An empty registry, decoupled from the
        ``prometheus_client`` default singleton so tests don't bleed
        metrics between runs.

    Raises:
        ImportError: When the ``[prometheus]`` extra is missing.
    """
    _require_prometheus()
    return CollectorRegistry()

tempest_fastapi_sdk.auth

UserAuthService

UserAuthService(
    *,
    user_model: type[BaseUserModel],
    token_model: type[BaseUserTokenModel],
    auth_settings: AuthSettings,
    jwt_settings: JWTSettings,
    email: EmailUtils | None = None,
    passwords: PasswordUtils | None = None,
    jwt: JWTUtils | None = None,
    db: AsyncDatabaseManager | None = None,
)

Compose UserModel + UserTokenModel into a full auth flow.

Example:

>>> service = UserAuthService(
...     db=db,
...     user_model=UserModel,
...     token_model=UserTokenModel,
...     auth_settings=settings,
...     jwt_settings=settings,
...     email=email_utils,
... )
>>> async with db.get_session_context() as s:
...     result = await service.signup(s, payload)

Every method takes the active AsyncSession explicitly so callers control the transaction boundary — the service never opens its own session.

Initialize the service.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

Concrete user model — usually src.db.models.UserModel.

required
token_model type[BaseUserTokenModel]

Concrete token model — usually src.db.models.UserTokenModel.

required
auth_settings AuthSettings

The mixin populating activation / reset behavior.

required
jwt_settings JWTSettings

The mixin populating signing keys and TTLs.

required
email EmailUtils | None

Configured email helper. When None, the service always returns the link in the response (and never tries to send).

None
passwords PasswordUtils | None

Override for tests; defaults to a fresh instance.

None
jwt JWTUtils | None

Override for tests; defaults to one built from jwt_settings.

None
db AsyncDatabaseManager | None

Optional handle for services that open their own sessions inside helpers like background tasks.

None
Source code in tempest_fastapi_sdk/auth/service.py
def __init__(
    self,
    *,
    user_model: type[BaseUserModel],
    token_model: type[BaseUserTokenModel],
    auth_settings: AuthSettings,
    jwt_settings: JWTSettings,
    email: EmailUtils | None = None,
    passwords: PasswordUtils | None = None,
    jwt: JWTUtils | None = None,
    db: AsyncDatabaseManager | None = None,
) -> None:
    """Initialize the service.

    Args:
        user_model (type[BaseUserModel]): Concrete user model
            — usually ``src.db.models.UserModel``.
        token_model (type[BaseUserTokenModel]): Concrete token
            model — usually ``src.db.models.UserTokenModel``.
        auth_settings (AuthSettings): The mixin populating
            activation / reset behavior.
        jwt_settings (JWTSettings): The mixin populating
            signing keys and TTLs.
        email (EmailUtils | None): Configured email helper.
            When ``None``, the service always returns the link
            in the response (and never tries to send).
        passwords (PasswordUtils | None): Override for tests;
            defaults to a fresh instance.
        jwt (JWTUtils | None): Override for tests; defaults
            to one built from ``jwt_settings``.
        db (AsyncDatabaseManager | None): Optional handle for
            services that open their own sessions inside
            helpers like background tasks.
    """
    self.user_model: type[BaseUserModel] = user_model
    self.token_model: type[BaseUserTokenModel] = token_model
    self.auth_settings: AuthSettings = auth_settings
    self.jwt_settings: JWTSettings = jwt_settings
    self.email: EmailUtils | None = email
    self.passwords: PasswordUtils = passwords or PasswordUtils()
    self.jwt: JWTUtils = jwt or JWTUtils(
        secret=jwt_settings.JWT_SECRET,
        algorithm=jwt_settings.JWT_ALGORITHM,
    )
    self.db: AsyncDatabaseManager | None = db

signup async

signup(
    session: AsyncSession, *, email: str, password: str, name: str | None = None
) -> tuple[BaseUserModel, ActivationToken | None]

Create a user row and (optionally) issue an activation token.

When AUTH_AUTO_ACTIVATE is true, the user is marked is_active=True immediately and None is returned in the second tuple slot — the caller can mint JWTs right away. Otherwise the user is inserted with is_active=False and an activation token is returned for the caller to mail or echo back.

Parameters:

Name Type Description Default
session AsyncSession

Active SQLAlchemy session.

required
email str

Account email — normalized to lowercase.

required
password str

Plaintext password; length is enforced against AUTH_PASSWORD_MIN_LENGTH.

required
name str | None

Optional display name; passed through to the model when the column exists.

None

Returns:

Type Description
BaseUserModel

tuple[BaseUserModel, ActivationToken | None]: The

ActivationToken | None

persisted user and (when activation is required) the

tuple[BaseUserModel, ActivationToken | None]

token to surface.

Raises:

Type Description
ValidationException

When the password is too short.

ConflictException

When the email is already taken.

Source code in tempest_fastapi_sdk/auth/service.py
async def signup(
    self,
    session: AsyncSession,
    *,
    email: str,
    password: str,
    name: str | None = None,
) -> tuple[BaseUserModel, ActivationToken | None]:
    """Create a user row and (optionally) issue an activation token.

    When ``AUTH_AUTO_ACTIVATE`` is true, the user is marked
    ``is_active=True`` immediately and ``None`` is returned in
    the second tuple slot — the caller can mint JWTs right
    away. Otherwise the user is inserted with ``is_active=False``
    and an activation token is returned for the caller to mail
    or echo back.

    Args:
        session (AsyncSession): Active SQLAlchemy session.
        email (str): Account email — normalized to lowercase.
        password (str): Plaintext password; length is enforced
            against ``AUTH_PASSWORD_MIN_LENGTH``.
        name (str | None): Optional display name; passed
            through to the model when the column exists.

    Returns:
        tuple[BaseUserModel, ActivationToken | None]: The
        persisted user and (when activation is required) the
        token to surface.

    Raises:
        ValidationException: When the password is too short.
        ConflictException: When the email is already taken.
    """
    self._enforce_password_policy(password)
    normalized = email.strip().lower()
    existing = await session.execute(
        select(self.user_model).where(
            self.user_model.email == normalized,
        )
    )
    if existing.scalar_one_or_none() is not None:
        raise ConflictException(
            message="email already in use",
            details={"email": normalized},
        )

    user = self.user_model(
        email=normalized,
        is_active=self.auth_settings.AUTH_AUTO_ACTIVATE,
    )
    user.hashed_password = self.passwords.hash(password)
    if name is not None and hasattr(user, "name"):
        user.name = name
    session.add(user)
    await session.flush()
    await session.refresh(user)

    if self.auth_settings.AUTH_AUTO_ACTIVATE:
        return user, None

    activation = await self._issue_token(
        session,
        user_id=user.id,
        purpose=UserTokenPurpose.ACTIVATION,
        ttl_seconds=self.auth_settings.AUTH_ACTIVATION_TTL_SECONDS,
        url_template=self.auth_settings.AUTH_ACTIVATION_URL_TEMPLATE,
    )
    await self._maybe_send_activation_email(user, activation)
    return user, ActivationToken(
        user_id=user.id,
        token=activation[0],
        url=activation[1],
        expires_at=activation[2],
    )

activate async

activate(session: AsyncSession, *, token: str) -> BaseUserModel

Consume an activation token and flip is_active on the user.

Parameters:

Name Type Description Default
session AsyncSession

Active SQLAlchemy session.

required
token str

Plaintext token from the activation URL.

required

Returns:

Name Type Description
BaseUserModel BaseUserModel

The freshly-activated user.

Raises:

Type Description
InvalidTokenException

When the token is malformed, expired, already used, or doesn't match a row.

Source code in tempest_fastapi_sdk/auth/service.py
async def activate(
    self,
    session: AsyncSession,
    *,
    token: str,
) -> BaseUserModel:
    """Consume an activation token and flip ``is_active`` on the user.

    Args:
        session (AsyncSession): Active SQLAlchemy session.
        token (str): Plaintext token from the activation URL.

    Returns:
        BaseUserModel: The freshly-activated user.

    Raises:
        InvalidTokenException: When the token is malformed,
            expired, already used, or doesn't match a row.
    """
    record = await self._consume_token(
        session,
        token=token,
        purpose=UserTokenPurpose.ACTIVATION,
    )
    user: BaseUserModel | None = await session.get(self.user_model, record.user_id)
    if user is None:
        raise InvalidTokenException(message="token references a missing user")
    user.is_active = True
    await session.flush()
    await session.refresh(user)
    return user

login async

login(session: AsyncSession, *, email: str, password: str) -> BaseUserModel

Validate credentials and return the matching user row.

Parameters:

Name Type Description Default
session AsyncSession

Active SQLAlchemy session.

required
email str

Login identifier.

required
password str

Plaintext password.

required

Returns:

Name Type Description
BaseUserModel BaseUserModel

The authenticated user.

Raises:

Type Description
UnauthorizedException

On any failure — wrong password, missing user, inactive user. The message is deliberately generic so attackers can't enumerate accounts.

Source code in tempest_fastapi_sdk/auth/service.py
async def login(
    self,
    session: AsyncSession,
    *,
    email: str,
    password: str,
) -> BaseUserModel:
    """Validate credentials and return the matching user row.

    Args:
        session (AsyncSession): Active SQLAlchemy session.
        email (str): Login identifier.
        password (str): Plaintext password.

    Returns:
        BaseUserModel: The authenticated user.

    Raises:
        UnauthorizedException: On any failure — wrong password,
            missing user, inactive user. The message is
            deliberately generic so attackers can't enumerate
            accounts.
    """
    normalized = email.strip().lower()
    user_result = await session.execute(
        select(self.user_model).where(
            self.user_model.email == normalized,
        )
    )
    user_obj = user_result.scalar_one_or_none()
    user: BaseUserModel | None = user_obj
    if user is None or not user.is_active:
        raise UnauthorizedException(message="invalid email or password")
    if not self.passwords.verify(password, user.hashed_password):
        raise UnauthorizedException(message="invalid email or password")
    user.last_login_at = utcnow()
    await session.flush()
    await session.refresh(user)
    return user

request_password_reset async

request_password_reset(
    session: AsyncSession, *, email: str
) -> PasswordResetToken | None

Mint a one-shot reset token for email.

Returns None when no user matches — callers should still respond 202 to avoid leaking account existence. Sends the email (when EmailUtils is wired) or returns the token for inline display per the AUTH_RETURN_TOKEN_IN_RESPONSE flag.

Parameters:

Name Type Description Default
session AsyncSession

Active SQLAlchemy session.

required
email str

Account email.

required

Returns:

Type Description
PasswordResetToken | None

PasswordResetToken | None: The token bundle when the

PasswordResetToken | None

caller is configured to surface the link, None

PasswordResetToken | None

when the link is meant to live only in the email.

Source code in tempest_fastapi_sdk/auth/service.py
async def request_password_reset(
    self,
    session: AsyncSession,
    *,
    email: str,
) -> PasswordResetToken | None:
    """Mint a one-shot reset token for ``email``.

    Returns ``None`` when no user matches — callers should
    still respond ``202`` to avoid leaking account
    existence. Sends the email (when ``EmailUtils`` is wired)
    or returns the token for inline display per the
    ``AUTH_RETURN_TOKEN_IN_RESPONSE`` flag.

    Args:
        session (AsyncSession): Active SQLAlchemy session.
        email (str): Account email.

    Returns:
        PasswordResetToken | None: The token bundle when the
        caller is configured to surface the link, ``None``
        when the link is meant to live only in the email.
    """
    normalized = email.strip().lower()
    user_result = await session.execute(
        select(self.user_model).where(
            self.user_model.email == normalized,
        )
    )
    user_obj = user_result.scalar_one_or_none()
    user: BaseUserModel | None = user_obj
    if user is None:
        return None

    reset = await self._issue_token(
        session,
        user_id=user.id,
        purpose=UserTokenPurpose.PASSWORD_RESET,
        ttl_seconds=self.auth_settings.AUTH_PASSWORD_RESET_TTL_SECONDS,
        url_template=self.auth_settings.AUTH_PASSWORD_RESET_URL_TEMPLATE,
    )
    await self._maybe_send_password_reset_email(user, reset)

    if self.auth_settings.AUTH_RETURN_TOKEN_IN_RESPONSE or self.email is None:
        return PasswordResetToken(
            user_id=user.id,
            token=reset[0],
            url=reset[1],
            expires_at=reset[2],
        )
    return None

confirm_password_reset async

confirm_password_reset(
    session: AsyncSession, *, token: str, new_password: str
) -> BaseUserModel

Consume a reset token and replace the user's password.

Parameters:

Name Type Description Default
session AsyncSession

Active SQLAlchemy session.

required
token str

Plaintext token from the reset URL.

required
new_password str

Plaintext replacement password.

required

Returns:

Name Type Description
BaseUserModel BaseUserModel

The user whose password was rotated.

Raises:

Type Description
ValidationException

When the new password is too short.

InvalidTokenException

On bad / expired / spent tokens.

Source code in tempest_fastapi_sdk/auth/service.py
async def confirm_password_reset(
    self,
    session: AsyncSession,
    *,
    token: str,
    new_password: str,
) -> BaseUserModel:
    """Consume a reset token and replace the user's password.

    Args:
        session (AsyncSession): Active SQLAlchemy session.
        token (str): Plaintext token from the reset URL.
        new_password (str): Plaintext replacement password.

    Returns:
        BaseUserModel: The user whose password was rotated.

    Raises:
        ValidationException: When the new password is too short.
        InvalidTokenException: On bad / expired / spent tokens.
    """
    self._enforce_password_policy(new_password)
    record = await self._consume_token(
        session,
        token=token,
        purpose=UserTokenPurpose.PASSWORD_RESET,
    )
    user: BaseUserModel | None = await session.get(self.user_model, record.user_id)
    if user is None:
        raise NotFoundException(message="user not found")
    user.hashed_password = self.passwords.hash(new_password)
    await session.flush()
    await session.refresh(user)
    return user

issue_jwt_pair

issue_jwt_pair(user: BaseUserModel) -> tuple[str, str]

Return (access, refresh) JWTs for an authenticated user.

Parameters:

Name Type Description Default
user BaseUserModel

The authenticated user.

required

Returns:

Type Description
tuple[str, str]

tuple[str, str]: (access_token, refresh_token).

Source code in tempest_fastapi_sdk/auth/service.py
def issue_jwt_pair(self, user: BaseUserModel) -> tuple[str, str]:
    """Return ``(access, refresh)`` JWTs for an authenticated user.

    Args:
        user (BaseUserModel): The authenticated user.

    Returns:
        tuple[str, str]: ``(access_token, refresh_token)``.
    """
    access = self.jwt.encode(
        {"sub": str(user.id), "email": user.email},
        ttl=timedelta(seconds=self.jwt_settings.JWT_ACCESS_TTL_SECONDS),
    )
    refresh = self.jwt.encode(
        {"sub": str(user.id), "refresh": True},
        ttl=timedelta(seconds=self.jwt_settings.JWT_REFRESH_TTL_SECONDS),
    )
    return access, refresh

peek_token async

peek_token(
    session: AsyncSession, *, token: str, purpose: UserTokenPurpose
) -> tuple[BaseUserTokenModel, BaseUserModel]

Validate a token + load its user without consuming it.

Mirrors :meth:_consume_token (raises on invalid/expired/already-used tokens) but leaves used_at untouched — used by GET endpoints in backend-only mode that need to render a page (e.g. the password-reset form) before the user actually submits.

Parameters:

Name Type Description Default
session AsyncSession

Active SQLAlchemy session.

required
token str

Plaintext token.

required
purpose UserTokenPurpose

Expected token purpose.

required

Returns:

Type Description
BaseUserTokenModel

tuple[BaseUserTokenModel, BaseUserModel]: The token

BaseUserModel

record and its associated user.

Raises:

Type Description
InvalidTokenException

On unknown / already-used / expired tokens.

NotFoundException

When the token references a user that no longer exists.

Source code in tempest_fastapi_sdk/auth/service.py
async def peek_token(
    self,
    session: AsyncSession,
    *,
    token: str,
    purpose: UserTokenPurpose,
) -> tuple[BaseUserTokenModel, BaseUserModel]:
    """Validate a token + load its user **without** consuming it.

    Mirrors :meth:`_consume_token` (raises on
    invalid/expired/already-used tokens) but leaves
    ``used_at`` untouched — used by ``GET`` endpoints in
    backend-only mode that need to render a page (e.g. the
    password-reset form) before the user actually submits.

    Args:
        session (AsyncSession): Active SQLAlchemy session.
        token (str): Plaintext token.
        purpose (UserTokenPurpose): Expected token purpose.

    Returns:
        tuple[BaseUserTokenModel, BaseUserModel]: The token
        record and its associated user.

    Raises:
        InvalidTokenException: On unknown / already-used /
            expired tokens.
        NotFoundException: When the token references a user
            that no longer exists.
    """
    record = await self._lookup_token(session, token=token, purpose=purpose)
    user: BaseUserModel | None = await session.get(self.user_model, record.user_id)
    if user is None:
        raise NotFoundException(message="user not found")
    return record, user

make_auth_router

make_auth_router(
    service: UserAuthService,
    *,
    session_factory: Callable[[], AsyncIterator[AsyncSession]],
    prefix: str = "/auth",
    tags: list[str] | None = None,
    template_dir: str | None = None,
) -> APIRouter

Build the bundled auth router.

Parameters:

Name Type Description Default
service UserAuthService

The configured service handling signup / activation / reset.

required
session_factory Callable[[], AsyncIterator[AsyncSession]]

FastAPI dependency yielding an async session. Typically wired as db.session_dependency where db is an :class:AsyncDatabaseManager. Used inside each handler to scope the transaction to the request.

required
prefix str

URL prefix; defaults to "/auth".

'/auth'
tags list[str] | None

OpenAPI tags. Defaults to ["auth"].

None
template_dir str | None

Optional directory holding HTML templates that override the SDK-bundled activation_success.html / activation_error.html / password_reset_form.html / password_reset_success.html / password_reset_error.html. Only consulted when AuthSettings.AUTH_BACKEND_LINKS=True.

None

Returns:

Name Type Description
APIRouter APIRouter

Ready to mount with app.include_router.

Source code in tempest_fastapi_sdk/auth/router.py
def make_auth_router(
    service: UserAuthService,
    *,
    session_factory: Callable[[], AsyncIterator[AsyncSession]],
    prefix: str = "/auth",
    tags: list[str] | None = None,
    template_dir: str | None = None,
) -> APIRouter:
    """Build the bundled auth router.

    Args:
        service (UserAuthService): The configured service handling
            signup / activation / reset.
        session_factory (Callable[[], AsyncIterator[AsyncSession]]):
            FastAPI dependency yielding an async session. Typically
            wired as ``db.session_dependency`` where ``db`` is
            an :class:`AsyncDatabaseManager`. Used inside each
            handler to scope the transaction to the request.
        prefix (str): URL prefix; defaults to ``"/auth"``.
        tags (list[str] | None): OpenAPI tags. Defaults to
            ``["auth"]``.
        template_dir (str | None): Optional directory holding
            HTML templates that override the SDK-bundled
            ``activation_success.html`` /
            ``activation_error.html`` /
            ``password_reset_form.html`` /
            ``password_reset_success.html`` /
            ``password_reset_error.html``. Only consulted when
            ``AuthSettings.AUTH_BACKEND_LINKS=True``.

    Returns:
        APIRouter: Ready to mount with ``app.include_router``.
    """
    from fastapi import Depends

    router = APIRouter(
        prefix=prefix,
        tags=list(tags or ["auth"]),
    )

    async def _session() -> AsyncIterator[AsyncSession]:
        async for s in session_factory():
            yield s

    session_dep = Depends(_session)

    auth_settings = service.auth_settings
    backend_links = auth_settings.AUTH_BACKEND_LINKS
    login_url = auth_settings.AUTH_LOGIN_URL
    min_length = auth_settings.AUTH_PASSWORD_MIN_LENGTH

    def _render_error(template: str, reason: str) -> HTMLResponse:
        html = render_auth_page(
            template,
            {"reason": reason, "login_url": login_url},
            template_dir=template_dir,
        )
        return HTMLResponse(content=html, status_code=400)

    # ------------------------------------------------------------------
    # JSON / SPA endpoints — always mounted.
    # ------------------------------------------------------------------

    @router.post(
        "/signup",
        response_model=SignupResponseSchema,
        status_code=status.HTTP_201_CREATED,
        summary="Create a new account",
        description=(
            "Creates a user with email + password. When "
            "``AUTH_AUTO_ACTIVATE`` is set the response carries JWT "
            "tokens directly; otherwise the user must confirm via "
            "the activation link before logging in."
        ),
    )
    async def signup(
        payload: SignupSchema,
        session: AsyncSession = session_dep,
    ) -> SignupResponseSchema:
        user, activation = await service.signup(
            session,
            email=payload.email,
            password=payload.password,
            name=payload.name,
        )
        await session.commit()
        if activation is None:
            access, refresh = service.issue_jwt_pair(user)
            return SignupResponseSchema(
                user_id=user.id,
                activation_required=False,
                activation_url=None,
                access_token=access,
                refresh_token=refresh,
            )
        return_url = (
            activation.url
            if service.auth_settings.AUTH_RETURN_TOKEN_IN_RESPONSE
            or service.email is None
            else None
        )
        return SignupResponseSchema(
            user_id=user.id,
            activation_required=True,
            activation_url=return_url,
        )

    @router.post(
        "/activate/{token}",
        response_model=ActivationResponseSchema,
        summary="Activate the account using the emailed token",
    )
    async def activate(
        token: str,
        session: AsyncSession = session_dep,
    ) -> ActivationResponseSchema:
        user = await service.activate(session, token=token)
        access, refresh = service.issue_jwt_pair(user)
        await session.commit()
        return ActivationResponseSchema(
            user_id=user.id,
            access_token=access,
            refresh_token=refresh,
        )

    @router.post(
        "/login",
        response_model=LoginResponseSchema,
        summary="Log in with email + password",
    )
    async def login(
        payload: LoginSchema,
        session: AsyncSession = session_dep,
    ) -> LoginResponseSchema:
        user = await service.login(
            session,
            email=payload.email,
            password=payload.password,
        )
        access, refresh = service.issue_jwt_pair(user)
        await session.commit()
        return LoginResponseSchema(
            user_id=user.id,
            access_token=access,
            refresh_token=refresh,
        )

    @router.post(
        "/password-reset/request",
        response_model=PasswordResetResponseSchema,
        status_code=status.HTTP_202_ACCEPTED,
        summary="Request a password-reset link",
        description=(
            "Always returns 202 so attackers can't enumerate accounts "
            "by probing emails. The link is mailed when ``EmailUtils`` "
            "is wired; otherwise (or when "
            "``AUTH_RETURN_TOKEN_IN_RESPONSE`` is on) it ships in the "
            "response body."
        ),
    )
    async def password_reset_request(
        payload: PasswordResetRequestSchema,
        session: AsyncSession = session_dep,
    ) -> PasswordResetResponseSchema:
        token = await service.request_password_reset(session, email=payload.email)
        await session.commit()
        message = "If the email matches an account, a reset link was sent."
        if token is None:
            return PasswordResetResponseSchema(message=message, reset_url=None)
        return PasswordResetResponseSchema(message=message, reset_url=token.url)

    @router.post(
        "/password-reset/confirm",
        response_model=LoginResponseSchema,
        summary="Confirm a password reset with the issued token",
    )
    async def password_reset_confirm(
        payload: PasswordResetConfirmSchema,
        session: AsyncSession = session_dep,
    ) -> LoginResponseSchema:
        user = await service.confirm_password_reset(
            session,
            token=payload.token,
            new_password=payload.new_password,
        )
        access, refresh = service.issue_jwt_pair(user)
        await session.commit()
        return LoginResponseSchema(
            user_id=user.id,
            access_token=access,
            refresh_token=refresh,
        )

    # ------------------------------------------------------------------
    # Backend-only HTML endpoints — mounted only when AUTH_BACKEND_LINKS.
    # ------------------------------------------------------------------

    if backend_links:

        @router.get(
            "/activate/{token}",
            response_class=HTMLResponse,
            include_in_schema=False,
            summary="Activate via emailed link (HTML page)",
        )
        async def activate_html(
            token: str,
            session: AsyncSession = session_dep,
        ) -> HTMLResponse:
            try:
                user = await service.activate(session, token=token)
            except InvalidTokenException as exc:
                await session.rollback()
                return _render_error(
                    auth_settings.AUTH_ACTIVATION_ERROR_TEMPLATE,
                    reason=exc.message,
                )
            await session.commit()
            html = render_auth_page(
                auth_settings.AUTH_ACTIVATION_SUCCESS_TEMPLATE,
                {"user": user, "login_url": login_url},
                template_dir=template_dir,
            )
            return HTMLResponse(content=html)

        @router.get(
            "/password-reset/{token}",
            response_class=HTMLResponse,
            include_in_schema=False,
            summary="Render the password-reset form for this token",
        )
        async def password_reset_form(
            token: str,
            session: AsyncSession = session_dep,
        ) -> HTMLResponse:
            try:
                _record, user = await service.peek_token(
                    session,
                    token=token,
                    purpose=UserTokenPurpose.PASSWORD_RESET,
                )
            except (InvalidTokenException, NotFoundException) as exc:
                return _render_error(
                    auth_settings.AUTH_PASSWORD_RESET_ERROR_TEMPLATE,
                    reason=exc.message,
                )
            html = render_auth_page(
                auth_settings.AUTH_PASSWORD_RESET_FORM_TEMPLATE,
                {
                    "user": user,
                    "form_action": f"{prefix}/password-reset/{token}",
                    "min_length": min_length,
                    "error": None,
                    "login_url": login_url,
                },
                template_dir=template_dir,
            )
            return HTMLResponse(content=html)

        @router.post(
            "/password-reset/{token}",
            response_class=HTMLResponse,
            include_in_schema=False,
            summary="Process the password-reset form (form-encoded)",
        )
        async def password_reset_form_submit(
            token: str,
            new_password: str = Form(...),
            confirm_password: str = Form(...),
            session: AsyncSession = session_dep,
        ) -> HTMLResponse:
            if new_password != confirm_password:
                try:
                    _record, user = await service.peek_token(
                        session,
                        token=token,
                        purpose=UserTokenPurpose.PASSWORD_RESET,
                    )
                except (InvalidTokenException, NotFoundException) as exc:
                    return _render_error(
                        auth_settings.AUTH_PASSWORD_RESET_ERROR_TEMPLATE,
                        reason=exc.message,
                    )
                html = render_auth_page(
                    auth_settings.AUTH_PASSWORD_RESET_FORM_TEMPLATE,
                    {
                        "user": user,
                        "form_action": f"{prefix}/password-reset/{token}",
                        "min_length": min_length,
                        "error": "Passwords do not match.",
                        "login_url": login_url,
                    },
                    template_dir=template_dir,
                )
                return HTMLResponse(content=html, status_code=400)
            try:
                user = await service.confirm_password_reset(
                    session,
                    token=token,
                    new_password=new_password,
                )
            except (InvalidTokenException, NotFoundException) as exc:
                await session.rollback()
                return _render_error(
                    auth_settings.AUTH_PASSWORD_RESET_ERROR_TEMPLATE,
                    reason=exc.message,
                )
            except ValidationException as exc:
                await session.rollback()
                try:
                    _record, peek_user = await service.peek_token(
                        session,
                        token=token,
                        purpose=UserTokenPurpose.PASSWORD_RESET,
                    )
                except (InvalidTokenException, NotFoundException):
                    return _render_error(
                        auth_settings.AUTH_PASSWORD_RESET_ERROR_TEMPLATE,
                        reason=exc.message,
                    )
                html = render_auth_page(
                    auth_settings.AUTH_PASSWORD_RESET_FORM_TEMPLATE,
                    {
                        "user": peek_user,
                        "form_action": f"{prefix}/password-reset/{token}",
                        "min_length": min_length,
                        "error": exc.message,
                        "login_url": login_url,
                    },
                    template_dir=template_dir,
                )
                return HTMLResponse(content=html, status_code=400)
            await session.commit()
            html = render_auth_page(
                auth_settings.AUTH_PASSWORD_RESET_SUCCESS_TEMPLATE,
                {"user": user, "login_url": login_url},
                template_dir=template_dir,
            )
            return HTMLResponse(content=html)

    return router

SignupSchema

Bases: BaseSchema

Request body for POST /auth/signup.

Carries the credentials and the optional display name a new account starts with. The email is normalized to lowercase before insert (matches the unique-index convention every SDK user table follows); the password is hashed with bcrypt by :class:tempest_fastapi_sdk.PasswordUtils and never stored in plaintext.

Attributes:

Name Type Description
email EmailStr

Login identifier — validated by email-validator so malformed addresses fail at the Pydantic layer (422) instead of at insert time.

password str

Plaintext password. Length floor is enforced both here (schema-level) and inside :class:UserAuthService (service-level redundancy on purpose — Pydantic validators don't fire on direct service.signup(...) calls from other code paths).

name str | None

Optional display name shown in the admin UI / front-end profile. None keeps the column NULL.

SignupResponseSchema

Bases: BaseSchema

Response body for POST /auth/signup.

The shape depends on the active settings:

  • When AUTH_AUTO_ACTIVATE=True the user is born active, activation_required=False and both access_token / refresh_token are populated — the client can log in immediately.
  • When AUTH_AUTO_ACTIVATE=False (production default) the user must confirm the activation link before logging in. activation_required=True, the tokens stay None and activation_url is set only when AUTH_RETURN_TOKEN_IN_RESPONSE=True (dev) or when the [email] extra isn't wired (so the link has to ship via the response instead of via SMTP).

Attributes:

Name Type Description
user_id UUID

Primary key of the freshly-inserted row.

activation_required bool

Whether the user still needs to confirm via the activation link.

activation_url str | None

Front-end URL the user must visit. None when the link travelled via email or activation was skipped.

access_token str | None

Short-lived JWT. Only set when activation_required=False.

refresh_token str | None

Long-lived JWT. Only set when activation_required=False.

LoginSchema

Bases: BaseSchema

Request body for POST /auth/login.

Standard email + password authentication. Both error paths (wrong password / unknown email / inactive user) collapse into the same generic UnauthorizedException so attackers can't enumerate accounts by reading the response.

Attributes:

Name Type Description
email EmailStr

Login identifier.

password str

Plaintext password — verified against the bcrypt hash stored on the row.

LoginResponseSchema

Bases: BaseSchema

Response body for POST /auth/login and the password-reset confirm.

Issued only when credentials validate; the bundled router reuses this shape for both POST /auth/login and POST /auth/password-reset/confirm since both flows end with an authenticated session.

Attributes:

Name Type Description
user_id UUID

UUID of the authenticated user.

access_token str

Short-lived JWT.

refresh_token str

Long-lived JWT.

ActivationResponseSchema

Bases: BaseSchema

Response body for POST /auth/activate/{token}.

Returned after the SDK has consumed a one-shot activation token and flipped the user's is_active=True. The user is automatically logged in — both JWTs are issued so the front-end can complete the post-confirmation redirect in one round-trip.

Attributes:

Name Type Description
user_id UUID

UUID of the freshly-activated user.

access_token str

Short-lived JWT.

refresh_token str

Long-lived JWT.

PasswordResetRequestSchema

Bases: BaseSchema

Request body for POST /auth/password-reset/request.

The endpoint always returns 202 with a generic message — even when the email isn't on file — so probing the endpoint can't enumerate accounts. The reset link travels via email (production) or in the response body when AUTH_RETURN_TOKEN_IN_RESPONSE=True (dev).

Attributes:

Name Type Description
email EmailStr

Email of the account asking for a reset.

PasswordResetResponseSchema

Bases: BaseSchema

Response body for POST /auth/password-reset/request.

message is the same generic string regardless of whether the email matched an account. reset_url is populated only when AUTH_RETURN_TOKEN_IN_RESPONSE=True or when the [email] extra isn't installed — otherwise the link only travels through SMTP.

Attributes:

Name Type Description
message str

Human-readable summary of the next step. Always identical across the "email found" / "email not found" branches.

reset_url str | None

Front-end reset URL when the caller asked for an inline response, None in production.

PasswordResetConfirmSchema

Bases: BaseSchema

Request body for POST /auth/password-reset/confirm.

Carries the opaque token the user copied from the reset link plus the replacement password. The service consumes the token (one-shot — used_at is stamped) and replaces the bcrypt hash atomically.

Attributes:

Name Type Description
token str

Opaque token issued by request. The plaintext form — the SDK stores only the hash, so this value cannot be guessed from the database.

new_password str

Plaintext replacement password. Length floor is enforced both schema-side and inside the service.

ActivationToken

Bases: BaseSchema

Service-level result of issuing an account-activation token.

Returned by :meth:UserAuthService.signup when activation is required — i.e. when AUTH_AUTO_ACTIVATE is false. The plaintext token is included here exactly once; only its SHA-256 hash is persisted, so this value cannot be recovered later. Use it to mail the activation link, log it during tests, or hand it back to the client in dev mode.

Attributes:

Name Type Description
user_id UUID

UUID of the user the token authorizes.

token str

Plaintext token — show once, never store.

url str

Front-end activation URL with the token already substituted into AUTH_ACTIVATION_URL_TEMPLATE.

expires_at datetime

UTC timestamp the token becomes invalid (default 7 days after issuance).

PasswordResetToken

Bases: BaseSchema

Service-level result of issuing a password-reset token.

Returned by :meth:UserAuthService.request_password_reset when the email matches a user and the caller asked the service to surface the link (either via AUTH_RETURN_TOKEN_IN_RESPONSE=True or because no :class:EmailUtils was wired). The plaintext token is one-shot, hashed at rest, and expires after AUTH_PASSWORD_RESET_TTL_SECONDS (default 1 hour).

Attributes:

Name Type Description
user_id UUID

UUID of the user whose password the token authorizes resetting.

token str

Plaintext token — display once, never store.

url str

Front-end reset URL with the token already substituted into AUTH_PASSWORD_RESET_URL_TEMPLATE.

expires_at datetime

UTC timestamp the token becomes invalid.

tempest_fastapi_sdk.sessions

SessionAuth

SessionAuth(
    *,
    user_model: type[BaseUserModel],
    store: SessionStore,
    settings: SessionSettings,
    passwords: PasswordUtils | None = None,
)

Server-side session lifecycle orchestrator.

Mount one instance per FastAPI app. Stateless — the only state lives in the injected :class:SessionStore and the project's UserModel table.

Initialize the service.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

Concrete user model (typically src.db.models.UserModel) used to resolve email → password hash on authenticate.

required
store SessionStore

Persistence backend (:class:MemorySessionStore or :class:RedisSessionStore).

required
settings SessionSettings

TTL / cookie / rotation flags driving the lifecycle.

required
passwords PasswordUtils | None

Override for tests; defaults to a fresh PasswordUtils().

None
Source code in tempest_fastapi_sdk/sessions/service.py
def __init__(
    self,
    *,
    user_model: type[BaseUserModel],
    store: SessionStore,
    settings: SessionSettings,
    passwords: PasswordUtils | None = None,
) -> None:
    """Initialize the service.

    Args:
        user_model (type[BaseUserModel]): Concrete user model
            (typically ``src.db.models.UserModel``) used to
            resolve email → password hash on ``authenticate``.
        store (SessionStore): Persistence backend
            (:class:`MemorySessionStore` or
            :class:`RedisSessionStore`).
        settings (SessionSettings): TTL / cookie / rotation
            flags driving the lifecycle.
        passwords (PasswordUtils | None): Override for tests;
            defaults to a fresh ``PasswordUtils()``.
    """
    self.user_model: type[BaseUserModel] = user_model
    self.store: SessionStore = store
    self.settings: SessionSettings = settings
    self.passwords: PasswordUtils = passwords or PasswordUtils()

authenticate async

authenticate(session: AsyncSession, *, email: str, password: str) -> BaseUserModel

Validate credentials and return the matching user row.

Parameters:

Name Type Description Default
session AsyncSession

Active SQLAlchemy session.

required
email str

Account email.

required
password str

Plaintext password.

required

Returns:

Name Type Description
BaseUserModel BaseUserModel

The authenticated user.

Raises:

Type Description
UnauthorizedException

On any failure — wrong password, missing user, inactive user. The message is deliberately generic so attackers cannot enumerate accounts via timing or wording.

Source code in tempest_fastapi_sdk/sessions/service.py
async def authenticate(
    self,
    session: AsyncSession,
    *,
    email: str,
    password: str,
) -> BaseUserModel:
    """Validate credentials and return the matching user row.

    Args:
        session (AsyncSession): Active SQLAlchemy session.
        email (str): Account email.
        password (str): Plaintext password.

    Returns:
        BaseUserModel: The authenticated user.

    Raises:
        UnauthorizedException: On any failure — wrong password,
            missing user, inactive user. The message is
            deliberately generic so attackers cannot enumerate
            accounts via timing or wording.
    """
    normalized = email.strip().lower()
    result = await session.execute(
        select(self.user_model).where(self.user_model.email == normalized),
    )
    user_obj = result.scalar_one_or_none()
    if user_obj is None or not user_obj.is_active:
        raise UnauthorizedException(message="invalid email or password")
    if not self.passwords.verify(password, user_obj.hashed_password):
        raise UnauthorizedException(message="invalid email or password")
    user_obj.last_login_at = utcnow()
    await session.flush()
    await session.refresh(user_obj)
    return user_obj

login async

login(
    *,
    user_id: UUID,
    ip: str | None = None,
    user_agent: str | None = None,
    previous_session_id: str | None = None,
) -> tuple[Session, str]

Mint a brand-new session for user_id.

When :attr:SessionSettings.SESSION_ROTATE_ON_LOGIN is True (default) and previous_session_id is provided, the previous session is evicted before the new one is issued — closes the session-fixation attack window.

Parameters:

Name Type Description Default
user_id UUID

The user the session belongs to.

required
ip str | None

Client IP (resolve via :func:tempest_fastapi_sdk.get_client_ip).

None
user_agent str | None

Raw User-Agent header.

None
previous_session_id str | None

Plaintext cookie value of the session being replaced. Pass it when a request already carries a session cookie — typical during step-up login. Ignored when SESSION_ROTATE_ON_LOGIN is False.

None

Returns:

Type Description
Session

tuple[Session, str]: The persisted session row and the

str

plaintext id to ship via Set-Cookie. The plaintext

tuple[Session, str]

is not persisted — losing it means logging the user

tuple[Session, str]

out.

Source code in tempest_fastapi_sdk/sessions/service.py
async def login(
    self,
    *,
    user_id: UUID,
    ip: str | None = None,
    user_agent: str | None = None,
    previous_session_id: str | None = None,
) -> tuple[Session, str]:
    """Mint a brand-new session for ``user_id``.

    When :attr:`SessionSettings.SESSION_ROTATE_ON_LOGIN` is
    ``True`` (default) and ``previous_session_id`` is provided,
    the previous session is evicted before the new one is
    issued — closes the session-fixation attack window.

    Args:
        user_id (UUID): The user the session belongs to.
        ip (str | None): Client IP (resolve via
            :func:`tempest_fastapi_sdk.get_client_ip`).
        user_agent (str | None): Raw User-Agent header.
        previous_session_id (str | None): Plaintext cookie value
            of the session being replaced. Pass it when a
            request already carries a session cookie — typical
            during step-up login. Ignored when
            ``SESSION_ROTATE_ON_LOGIN`` is ``False``.

    Returns:
        tuple[Session, str]: The persisted session row and the
        plaintext id to ship via ``Set-Cookie``. The plaintext
        is **not** persisted — losing it means logging the user
        out.
    """
    if self.settings.SESSION_ROTATE_ON_LOGIN and previous_session_id is not None:
        await self.revoke(previous_session_id)
    plaintext, session_hash = generate_opaque_token()
    now = utcnow()
    session = Session(
        session_id=session_hash,
        user_id=user_id,
        created_at=now,
        expires_at=now + timedelta(seconds=self.settings.SESSION_TTL_SECONDS),
        last_seen_at=now,
        ip=ip,
        user_agent=user_agent,
        data={},
    )
    await self.store.set(session)
    return session, plaintext

resolve async

resolve(session_id_plaintext: str) -> Session | None

Look up + (optionally) refresh a session by its cookie value.

Returns None when the cookie does not match a live session — middleware / dependencies treat that as "unauthenticated".

Parameters:

Name Type Description Default
session_id_plaintext str

Raw cookie value.

required

Returns:

Type Description
Session | None

Session | None: The resolved session, or None.

Source code in tempest_fastapi_sdk/sessions/service.py
async def resolve(self, session_id_plaintext: str) -> Session | None:
    """Look up + (optionally) refresh a session by its cookie value.

    Returns ``None`` when the cookie does not match a live
    session — middleware / dependencies treat that as
    "unauthenticated".

    Args:
        session_id_plaintext (str): Raw cookie value.

    Returns:
        Session | None: The resolved session, or ``None``.
    """
    session_hash = hash_opaque_token(session_id_plaintext)
    session = await self.store.get(session_hash)
    if session is None:
        return None
    now = utcnow()
    session.last_seen_at = now
    if self.settings.SESSION_SLIDING:
        session.expires_at = now + timedelta(
            seconds=self.settings.SESSION_TTL_SECONDS,
        )
    await self.store.set(session)
    return session

revoke async

revoke(session_id_plaintext: str) -> None

Invalidate one session by its plaintext id. Idempotent.

Source code in tempest_fastapi_sdk/sessions/service.py
async def revoke(self, session_id_plaintext: str) -> None:
    """Invalidate one session by its plaintext id. Idempotent."""
    session_hash = hash_opaque_token(session_id_plaintext)
    await self.store.delete(session_hash)

revoke_all async

revoke_all(user_id: UUID) -> int

Invalidate every session for user_id. Returns count revoked.

Source code in tempest_fastapi_sdk/sessions/service.py
async def revoke_all(self, user_id: UUID) -> int:
    """Invalidate every session for ``user_id``. Returns count revoked."""
    return await self.store.delete_by_user(user_id)

list_sessions async

list_sessions(
    user_id: UUID, *, current_session_id_plaintext: str | None = None
) -> list[SessionSummarySchema]

Return public-safe summaries of user_id's sessions.

Parameters:

Name Type Description Default
user_id UUID

Owner whose sessions to list.

required
current_session_id_plaintext str | None

Optional plaintext cookie of the session resolving the current request — used to set is_current=True on the matching row.

None

Returns:

Type Description
list[SessionSummarySchema]

list[SessionSummarySchema]: One entry per live

list[SessionSummarySchema]

session, oldest first.

Source code in tempest_fastapi_sdk/sessions/service.py
async def list_sessions(
    self,
    user_id: UUID,
    *,
    current_session_id_plaintext: str | None = None,
) -> list[SessionSummarySchema]:
    """Return public-safe summaries of ``user_id``'s sessions.

    Args:
        user_id (UUID): Owner whose sessions to list.
        current_session_id_plaintext (str | None): Optional
            plaintext cookie of the session resolving the
            current request — used to set ``is_current=True``
            on the matching row.

    Returns:
        list[SessionSummarySchema]: One entry per live
        session, oldest first.
    """
    current_hash: str | None = None
    if current_session_id_plaintext is not None:
        current_hash = hash_opaque_token(current_session_id_plaintext)
    sessions = await self.store.list_by_user(user_id)
    summaries: list[SessionSummarySchema] = []
    for session in sessions:
        summaries.append(
            SessionSummarySchema(
                id=session.session_id[:32],
                created_at=session.created_at,
                expires_at=session.expires_at,
                last_seen_at=session.last_seen_at,
                ip=session.ip,
                user_agent=session.user_agent,
                is_current=(session.session_id == current_hash),
            )
        )
    return summaries

revoke_by_public_id async

revoke_by_public_id(user_id: UUID, public_id: str) -> None

Revoke one session belonging to user_id via its public id.

The public id is the 32-char prefix of the hashed session id (see :class:SessionSummarySchema). The match scans the user's live sessions — fast in practice because users rarely have more than a handful.

Raises:

Type Description
NotFoundException

When no live session of user_id matches public_id.

Source code in tempest_fastapi_sdk/sessions/service.py
async def revoke_by_public_id(self, user_id: UUID, public_id: str) -> None:
    """Revoke one session belonging to ``user_id`` via its public id.

    The public id is the 32-char prefix of the hashed session
    id (see :class:`SessionSummarySchema`). The match scans the
    user's live sessions — fast in practice because users
    rarely have more than a handful.

    Raises:
        NotFoundException: When no live session of ``user_id``
            matches ``public_id``.
    """
    sessions = await self.store.list_by_user(user_id)
    for session in sessions:
        if session.session_id.startswith(public_id):
            await self.store.delete(session.session_id)
            return
    raise NotFoundException(message="session not found")

make_session_router

make_session_router(
    service: SessionAuth,
    *,
    session_factory: Callable[[], AsyncIterator[AsyncSession]],
    prefix: str = "/auth/session",
    tags: list[str] | None = None,
) -> APIRouter

Build the bundled session router.

Parameters:

Name Type Description Default
service SessionAuth

Configured auth service that talks to the store + verifies passwords against the user model.

required
session_factory Callable[[], AsyncIterator[AsyncSession]]

FastAPI dependency yielding an async SQLAlchemy session — typically db.session_dependency.

required
prefix str

URL prefix. Defaults to "/auth/session".

'/auth/session'
tags list[str] | None

OpenAPI tags. Defaults to ["session"].

None

Returns:

Name Type Description
APIRouter APIRouter

Ready to mount with app.include_router.

Source code in tempest_fastapi_sdk/sessions/router.py
def make_session_router(
    service: SessionAuth,
    *,
    session_factory: Callable[[], AsyncIterator[AsyncSession]],
    prefix: str = "/auth/session",
    tags: list[str] | None = None,
) -> APIRouter:
    """Build the bundled session router.

    Args:
        service (SessionAuth): Configured auth service that talks
            to the store + verifies passwords against the user
            model.
        session_factory (Callable[[], AsyncIterator[AsyncSession]]):
            FastAPI dependency yielding an async SQLAlchemy session
            — typically ``db.session_dependency``.
        prefix (str): URL prefix. Defaults to ``"/auth/session"``.
        tags (list[str] | None): OpenAPI tags. Defaults to
            ``["session"]``.

    Returns:
        APIRouter: Ready to mount with ``app.include_router``.
    """
    settings = service.settings
    router = APIRouter(prefix=prefix, tags=list(tags or ["session"]))

    async def _session_dep() -> AsyncIterator[AsyncSession]:
        async for s in session_factory():
            yield s

    db_dep = Depends(_session_dep)
    current_session_required = Depends(make_session_dependency(required=True))

    def _set_session_cookie(response: Response, plaintext: str) -> None:
        set_cookie(
            response,
            settings.SESSION_COOKIE_NAME,
            plaintext,
            max_age=settings.SESSION_TTL_SECONDS,
            path=settings.SESSION_COOKIE_PATH,
            domain=settings.SESSION_COOKIE_DOMAIN,
            secure=settings.SESSION_COOKIE_SECURE,
            http_only=settings.SESSION_COOKIE_HTTPONLY,
            samesite=_samesite(settings.SESSION_COOKIE_SAMESITE),
        )

    def _clear_session_cookie(response: Response) -> None:
        clear_cookie(
            response,
            settings.SESSION_COOKIE_NAME,
            path=settings.SESSION_COOKIE_PATH,
            domain=settings.SESSION_COOKIE_DOMAIN,
            samesite=_samesite(settings.SESSION_COOKIE_SAMESITE),
        )

    @router.post(
        "/login",
        response_model=SessionResponseSchema,
        status_code=status.HTTP_200_OK,
        summary="Authenticate and start a session",
    )
    async def login(
        payload: SessionLoginSchema,
        request: Request,
        response: Response,
        session: AsyncSession = db_dep,
    ) -> SessionResponseSchema:
        user = await service.authenticate(
            session,
            email=payload.email,
            password=payload.password,
        )
        await session.commit()
        previous = request.cookies.get(settings.SESSION_COOKIE_NAME)
        new_session, plaintext = await service.login(
            user_id=user.id,
            ip=get_client_ip(request),
            user_agent=request.headers.get("user-agent"),
            previous_session_id=previous,
        )
        _set_session_cookie(response, plaintext)
        return SessionResponseSchema(
            user_id=user.id,
            expires_at=new_session.expires_at,
        )

    @router.post(
        "/logout",
        status_code=status.HTTP_204_NO_CONTENT,
        summary="Revoke the current session",
    )
    async def logout(
        request: Request,
        response: Response,
    ) -> Response:
        cookie = request.cookies.get(settings.SESSION_COOKIE_NAME)
        if cookie:
            await service.revoke(cookie)
        _clear_session_cookie(response)
        response.status_code = status.HTTP_204_NO_CONTENT
        return response

    @router.get(
        "/me",
        response_model=Session,
        summary="Return the live session for the current cookie",
    )
    async def me(
        session: Session = current_session_required,
    ) -> Session:
        return session

    @router.get(
        "/list",
        response_model=list[SessionSummarySchema],
        summary="List every session the current user owns",
    )
    async def list_sessions(
        request: Request,
        session: Session = current_session_required,
    ) -> list[SessionSummarySchema]:
        current_plain: str | None = getattr(request.state, "session_id_plaintext", None)
        return await service.list_sessions(
            session.user_id,
            current_session_id_plaintext=current_plain,
        )

    @router.delete(
        "/{public_id}",
        status_code=status.HTTP_204_NO_CONTENT,
        summary="Revoke one specific session by its public id",
    )
    async def revoke_one(
        public_id: str,
        request: Request,
        response: Response,
        session: Session = current_session_required,
    ) -> Response:
        await service.revoke_by_public_id(session.user_id, public_id)
        # If the user revoked their own session, drop the cookie too.
        if session.session_id.startswith(public_id):
            _clear_session_cookie(response)
        response.status_code = status.HTTP_204_NO_CONTENT
        return response

    return router

SessionMiddleware

SessionMiddleware(
    app: ASGIApp, *, session_auth: SessionAuth, settings: SessionSettings
)

Bases: BaseHTTPMiddleware

ASGI middleware that resolves the session cookie per request.

Attach with::

app.add_middleware(
    SessionMiddleware,
    session_auth=session_auth,
    settings=session_settings,
)

After the middleware runs, every handler in the chain can read request.state.session — a :class:Session instance when the cookie was valid, None otherwise. Handlers that require authentication should depend on :func:make_session_dependency instead of poking request.state directly so missing sessions raise a clean 401 envelope.

Initialize the middleware.

Parameters:

Name Type Description Default
app ASGIApp

Wrapped ASGI app — Starlette passes this automatically when used with add_middleware.

required
session_auth SessionAuth

Configured service used to resolve cookies into sessions.

required
settings SessionSettings

Read for the cookie name.

required
Source code in tempest_fastapi_sdk/sessions/middleware.py
def __init__(
    self,
    app: ASGIApp,
    *,
    session_auth: SessionAuth,
    settings: SessionSettings,
) -> None:
    """Initialize the middleware.

    Args:
        app (ASGIApp): Wrapped ASGI app — Starlette passes this
            automatically when used with ``add_middleware``.
        session_auth (SessionAuth): Configured service used to
            resolve cookies into sessions.
        settings (SessionSettings): Read for the cookie name.
    """
    super().__init__(app)
    self.session_auth: SessionAuth = session_auth
    self.settings: SessionSettings = settings

dispatch async

dispatch(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response

Resolve the cookie before delegating to the route handler.

Source code in tempest_fastapi_sdk/sessions/middleware.py
async def dispatch(
    self,
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    """Resolve the cookie before delegating to the route handler."""
    cookie = request.cookies.get(self.settings.SESSION_COOKIE_NAME)
    request.state.session = None
    request.state.session_id_plaintext = cookie
    if cookie:
        request.state.session = await self.session_auth.resolve(cookie)
    return await call_next(request)

make_session_dependency

make_session_dependency(
    *, required: bool = True
) -> Callable[[Request], Session | None]

Build a FastAPI dependency that returns the resolved session.

The dependency reads request.state.session populated by :class:SessionMiddleware. Mount the middleware on the app BEFORE you use the dependency or it always returns None /raises UnauthorizedException.

Parameters:

Name Type Description Default
required bool

When True (default), missing sessions raise :class:UnauthorizedException so the SDK envelope returns 401. When False, the dependency returns None and the handler decides what to do (typical for endpoints that work both anonymously and authenticated).

True

Returns:

Type Description
Callable[[Request], Session | None]

A FastAPI dependency callable.

Source code in tempest_fastapi_sdk/sessions/dependencies.py
def make_session_dependency(
    *,
    required: bool = True,
) -> Callable[[Request], Session | None]:
    """Build a FastAPI dependency that returns the resolved session.

    The dependency reads ``request.state.session`` populated by
    :class:`SessionMiddleware`. Mount the middleware on the app
    BEFORE you use the dependency or it always returns ``None``
    /raises ``UnauthorizedException``.

    Args:
        required (bool): When ``True`` (default), missing sessions
            raise :class:`UnauthorizedException` so the SDK
            envelope returns ``401``. When ``False``, the
            dependency returns ``None`` and the handler decides
            what to do (typical for endpoints that work both
            anonymously and authenticated).

    Returns:
        A FastAPI dependency callable.
    """

    def _resolver(request: Request) -> Session | None:
        session: Session | None = getattr(request.state, "session", None)
        if session is None and required:
            raise UnauthorizedException(message="session required")
        return session

    return _resolver

SessionStore

Bases: Protocol

Persistence protocol every session backend implements.

get async

get(session_id_hash: str) -> Session | None

Return the live session for session_id_hash or None.

Source code in tempest_fastapi_sdk/sessions/store.py
async def get(self, session_id_hash: str) -> Session | None:
    """Return the live session for ``session_id_hash`` or ``None``."""
    ...

set async

set(session: Session) -> None

Persist (or overwrite) session.

Source code in tempest_fastapi_sdk/sessions/store.py
async def set(self, session: Session) -> None:
    """Persist (or overwrite) ``session``."""
    ...

delete async

delete(session_id_hash: str) -> None

Remove a single session. Idempotent.

Source code in tempest_fastapi_sdk/sessions/store.py
async def delete(self, session_id_hash: str) -> None:
    """Remove a single session. Idempotent."""
    ...

delete_by_user async

delete_by_user(user_id: UUID) -> int

Remove every session for user_id. Returns count deleted.

Source code in tempest_fastapi_sdk/sessions/store.py
async def delete_by_user(self, user_id: UUID) -> int:
    """Remove every session for ``user_id``. Returns count deleted."""
    ...

list_by_user async

list_by_user(user_id: UUID) -> list[Session]

Return every live session for user_id (oldest first).

Source code in tempest_fastapi_sdk/sessions/store.py
async def list_by_user(self, user_id: UUID) -> list[Session]:
    """Return every live session for ``user_id`` (oldest first)."""
    ...

MemorySessionStore

MemorySessionStore()

In-process :class:SessionStore for dev, tests, single-replica.

Stores sessions in a dict keyed by their hashed id; a secondary index by user_id powers list_by_user / delete_by_user without scanning. Expired rows are pruned on access — no background task needed.

Initialize the in-memory store.

Source code in tempest_fastapi_sdk/sessions/store.py
def __init__(self) -> None:
    """Initialize the in-memory store."""
    self._sessions: dict[str, Session] = {}
    self._by_user: dict[UUID, set[str]] = {}
    self._lock: asyncio.Lock = asyncio.Lock()

get async

get(session_id_hash: str) -> Session | None

Return the live session for session_id_hash or None.

Source code in tempest_fastapi_sdk/sessions/store.py
async def get(self, session_id_hash: str) -> Session | None:
    """Return the live session for ``session_id_hash`` or ``None``."""
    async with self._lock:
        session = self._sessions.get(session_id_hash)
        if session is None:
            return None
        if self._is_expired(session):
            self._evict_locked(session)
            return None
        return session

set async

set(session: Session) -> None

Persist (or overwrite) session.

Source code in tempest_fastapi_sdk/sessions/store.py
async def set(self, session: Session) -> None:
    """Persist (or overwrite) ``session``."""
    async with self._lock:
        existing = self._sessions.get(session.session_id)
        if existing is not None and existing.user_id != session.user_id:
            # Same id but new owner: keep indexes consistent.
            self._by_user.get(existing.user_id, set()).discard(
                session.session_id,
            )
        self._sessions[session.session_id] = session
        self._by_user.setdefault(session.user_id, set()).add(
            session.session_id,
        )

delete async

delete(session_id_hash: str) -> None

Remove a single session. Idempotent.

Source code in tempest_fastapi_sdk/sessions/store.py
async def delete(self, session_id_hash: str) -> None:
    """Remove a single session. Idempotent."""
    async with self._lock:
        session = self._sessions.pop(session_id_hash, None)
        if session is None:
            return
        self._by_user.get(session.user_id, set()).discard(session_id_hash)
        if not self._by_user.get(session.user_id):
            self._by_user.pop(session.user_id, None)

delete_by_user async

delete_by_user(user_id: UUID) -> int

Remove every session for user_id. Returns count deleted.

Source code in tempest_fastapi_sdk/sessions/store.py
async def delete_by_user(self, user_id: UUID) -> int:
    """Remove every session for ``user_id``. Returns count deleted."""
    async with self._lock:
        ids = list(self._by_user.pop(user_id, set()))
        for session_id in ids:
            self._sessions.pop(session_id, None)
        return len(ids)

list_by_user async

list_by_user(user_id: UUID) -> list[Session]

Return every live session for user_id (oldest first).

Source code in tempest_fastapi_sdk/sessions/store.py
async def list_by_user(self, user_id: UUID) -> list[Session]:
    """Return every live session for ``user_id`` (oldest first)."""
    async with self._lock:
        ids = list(self._by_user.get(user_id, set()))
        sessions = []
        for session_id in ids:
            session = self._sessions.get(session_id)
            if session is None:
                continue
            if self._is_expired(session):
                self._evict_locked(session)
                continue
            sessions.append(session)
        sessions.sort(key=lambda s: s.created_at)
        return sessions

RedisSessionStore

RedisSessionStore(client: Redis, *, prefix: str = 'tempest:')

:class:SessionStore backed by an async redis client.

Schema:

  • Each session is stored at {prefix}sess:{hash} as JSON with a TTL set to (expires_at - now) so Redis evicts the key on its own — no janitor process needed.
  • The user → session index lives at {prefix}user:{user_id} as a Redis SET of session hashes. Entries are removed on delete and the whole SET is dropped on delete_by_user.

Requires the [cache] extra so the redis async client is available.

Initialize the Redis-backed store.

Parameters:

Name Type Description Default
client Redis

Async Redis client (e.g. AsyncRedisManager.client).

required
prefix str

Key prefix so session keys do not collide with other cached data.

'tempest:'
Source code in tempest_fastapi_sdk/sessions/store.py
def __init__(
    self,
    client: Redis,
    *,
    prefix: str = "tempest:",
) -> None:
    """Initialize the Redis-backed store.

    Args:
        client (Redis): Async Redis client (e.g.
            ``AsyncRedisManager.client``).
        prefix (str): Key prefix so session keys do not collide
            with other cached data.
    """
    self.client: Redis = client
    self.prefix: str = prefix

get async

get(session_id_hash: str) -> Session | None

Return the live session for session_id_hash or None.

Source code in tempest_fastapi_sdk/sessions/store.py
async def get(self, session_id_hash: str) -> Session | None:
    """Return the live session for ``session_id_hash`` or ``None``."""
    raw = await self.client.get(self._session_key(session_id_hash))
    if raw is None:
        return None
    payload: dict[str, Any] = json.loads(raw)
    session = Session.model_validate(payload)
    if self._is_expired(session):
        await self.delete(session_id_hash)
        return None
    return session

set async

set(session: Session) -> None

Persist (or overwrite) session with a TTL matching expires_at.

Source code in tempest_fastapi_sdk/sessions/store.py
async def set(self, session: Session) -> None:
    """Persist (or overwrite) ``session`` with a TTL matching ``expires_at``."""
    ttl_seconds = max(
        1,
        int(
            (
                session.expires_at.replace(tzinfo=None)
                if session.expires_at.tzinfo is not None
                else session.expires_at
            ).timestamp()
            - utcnow().replace(tzinfo=None).timestamp()
        ),
    )
    payload = session.model_dump(mode="json")
    await self.client.set(
        self._session_key(session.session_id),
        json.dumps(payload),
        ex=ttl_seconds,
    )
    await self._sadd(self._user_key(session.user_id), session.session_id)
    # User index TTL grows with the longest-lived session — bump it.
    await self.client.expire(self._user_key(session.user_id), ttl_seconds)

delete async

delete(session_id_hash: str) -> None

Remove a single session. Idempotent.

Source code in tempest_fastapi_sdk/sessions/store.py
async def delete(self, session_id_hash: str) -> None:
    """Remove a single session. Idempotent."""
    raw = await self.client.get(self._session_key(session_id_hash))
    if raw is not None:
        payload = json.loads(raw)
        user_id = payload.get("user_id")
        if user_id:
            await self._srem(self._user_key(UUID(user_id)), session_id_hash)
    await self.client.delete(self._session_key(session_id_hash))

delete_by_user async

delete_by_user(user_id: UUID) -> int

Remove every session for user_id. Returns count deleted.

Source code in tempest_fastapi_sdk/sessions/store.py
async def delete_by_user(self, user_id: UUID) -> int:
    """Remove every session for ``user_id``. Returns count deleted."""
    decoded = await self._smembers(self._user_key(user_id))
    if decoded:
        await self.client.delete(
            *[self._session_key(sid) for sid in decoded],
        )
    await self.client.delete(self._user_key(user_id))
    return len(decoded)

list_by_user async

list_by_user(user_id: UUID) -> list[Session]

Return every live session for user_id (oldest first).

Source code in tempest_fastapi_sdk/sessions/store.py
async def list_by_user(self, user_id: UUID) -> list[Session]:
    """Return every live session for ``user_id`` (oldest first)."""
    decoded = await self._smembers(self._user_key(user_id))
    sessions: list[Session] = []
    stale: list[str] = []
    for session_id in decoded:
        raw = await self.client.get(self._session_key(session_id))
        if raw is None:
            stale.append(session_id)
            continue
        session = Session.model_validate(json.loads(raw))
        if self._is_expired(session):
            stale.append(session_id)
            continue
        sessions.append(session)
    if stale:
        await self._srem(self._user_key(user_id), *stale)
    sessions.sort(key=lambda s: s.created_at)
    return sessions

Session

Bases: BaseSchema

A live server-side session.

Stored in the configured :class:SessionStore keyed by the SHA-256 hash of the session id (the plaintext lives only in the cookie). Mirrors what every session-backed auth flow needs: user identity, lifetime bounds, originating client metadata for revocation UX ("you're signed in on Chrome from São Paulo"), and a free-form data bag for app-level state.

Attributes:

Name Type Description
session_id str

SHA-256 hex digest of the cookie value — NOT the plaintext. The plaintext leaves over Set-Cookie exactly once.

user_id UUID

Owner of the session.

created_at datetime

UTC timestamp when the session was issued.

expires_at datetime

UTC timestamp after which the session is rejected. Refreshed by :meth:SessionAuth.touch when sliding TTLs are in effect.

last_seen_at datetime

UTC timestamp of the last request that resolved the session. Updated by the middleware on every hit.

ip str | None

Client IP recorded at session creation. Useful for the "list active sessions" UI.

user_agent str | None

User-Agent header recorded at session creation.

data dict[str, Any]

Arbitrary JSON-serializable bag — shopping cart id, last-seen route, locale preference, etc.

SessionLoginSchema

Bases: BaseSchema

Payload for POST /auth/session/login.

SessionResponseSchema

Bases: BaseSchema

Body returned by POST /auth/session/login.

The session id itself is delivered via Set-Cookie — deliberately NOT in this body — so JavaScript cannot read it (HttpOnly cookies). The body carries everything the frontend actually needs to render an authenticated state.

SessionSummarySchema

Bases: BaseSchema

Public-safe projection of a :class:Session used by list endpoints.

Drops session_id (so revealing the list does NOT leak any secret) and renames the visible identifier to id — a stable UUID derived from the hashed session id by truncation, suitable for DELETE /auth/session/{id} revocation calls.

tempest_fastapi_sdk.storage

AsyncMinIOClient

AsyncMinIOClient(
    endpoint: str,
    access_key: str,
    secret_key: str,
    *,
    default_bucket: str = "uploads",
    secure: bool = False,
    region: str = "us-east-1",
    session_token: str | None = None,
)

Async-friendly facade over minio.Minio.

Use as an async context manager when you want explicit cleanup, or hold a long-lived instance on the FastAPI app — the underlying Minio client is thread-safe and reuses its connection pool.

Example:

>>> from tempest_fastapi_sdk import AsyncMinIOClient
>>> storage = AsyncMinIOClient(
...     endpoint="localhost:9000",
...     access_key="minioadmin",
...     secret_key="minioadmin",
...     default_bucket="uploads",
... )
>>> await storage.ensure_bucket()
>>> await storage.put_object("hello.txt", b"world")
>>> body = await storage.get_object_bytes("hello.txt")
>>> assert body == b"world"

Initialize the client.

Parameters:

Name Type Description Default
endpoint str

host[:port] without scheme.

required
access_key str

S3 access key.

required
secret_key str

S3 secret key.

required
default_bucket str

Bucket used by object operations when no explicit bucket keyword is passed. Created by :meth:ensure_bucket.

'uploads'
secure bool

Use HTTPS when True.

False
region str

S3 region. Match the bucket region for AWS S3; any value works for MinIO.

'us-east-1'
session_token str | None

Optional STS session token for temporary credentials.

None

Raises:

Type Description
ImportError

When the minio package is not installed. Install the [minio] extra: pip install tempest-fastapi-sdk[minio].

Source code in tempest_fastapi_sdk/storage/minio_client.py
def __init__(
    self,
    endpoint: str,
    access_key: str,
    secret_key: str,
    *,
    default_bucket: str = "uploads",
    secure: bool = False,
    region: str = "us-east-1",
    session_token: str | None = None,
) -> None:
    """Initialize the client.

    Args:
        endpoint (str): ``host[:port]`` without scheme.
        access_key (str): S3 access key.
        secret_key (str): S3 secret key.
        default_bucket (str): Bucket used by object operations
            when no explicit ``bucket`` keyword is passed.
            Created by :meth:`ensure_bucket`.
        secure (bool): Use HTTPS when ``True``.
        region (str): S3 region. Match the bucket region for
            AWS S3; any value works for MinIO.
        session_token (str | None): Optional STS session token
            for temporary credentials.

    Raises:
        ImportError: When the ``minio`` package is not
            installed. Install the ``[minio]`` extra:
            ``pip install tempest-fastapi-sdk[minio]``.
    """
    try:
        from minio import Minio
    except ImportError as exc:  # pragma: no cover - exercised via extras
        raise ImportError(
            "AsyncMinIOClient requires the 'minio' package. "
            "Install with: pip install tempest-fastapi-sdk[minio]"
        ) from exc

    self.endpoint: str = endpoint
    self.default_bucket: str = default_bucket
    self.region: str = region
    self.secure: bool = secure
    self.client: Minio = Minio(
        endpoint,
        access_key=access_key,
        secret_key=secret_key,
        secure=secure,
        region=region,
        session_token=session_token,
    )

bucket_exists async

bucket_exists(bucket: str | None = None) -> bool

Check whether bucket exists.

Parameters:

Name Type Description Default
bucket str | None

Target bucket; defaults to default_bucket.

None

Returns:

Name Type Description
bool bool

True when the bucket exists and is reachable

bool

with the configured credentials.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def bucket_exists(self, bucket: str | None = None) -> bool:
    """Check whether ``bucket`` exists.

    Args:
        bucket (str | None): Target bucket; defaults to
            ``default_bucket``.

    Returns:
        bool: ``True`` when the bucket exists and is reachable
        with the configured credentials.
    """
    target = self._bucket(bucket)
    return await asyncio.to_thread(self.client.bucket_exists, target)

ensure_bucket async

ensure_bucket(bucket: str | None = None) -> bool

Create the bucket if it does not exist yet.

Parameters:

Name Type Description Default
bucket str | None

Target bucket; defaults to default_bucket.

None

Returns:

Name Type Description
bool bool

True when a bucket was created, False

bool

when it already existed.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def ensure_bucket(self, bucket: str | None = None) -> bool:
    """Create the bucket if it does not exist yet.

    Args:
        bucket (str | None): Target bucket; defaults to
            ``default_bucket``.

    Returns:
        bool: ``True`` when a bucket was created, ``False``
        when it already existed.
    """
    target = self._bucket(bucket)

    def _ensure() -> bool:
        if self.client.bucket_exists(target):
            return False
        self.client.make_bucket(target, location=self.region)
        return True

    return await asyncio.to_thread(_ensure)

list_buckets async

list_buckets() -> list[str]

Return every bucket reachable with the current credentials.

Returns:

Type Description
list[str]

list[str]: Bucket names. Empty list when none exist.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def list_buckets(self) -> list[str]:
    """Return every bucket reachable with the current credentials.

    Returns:
        list[str]: Bucket names. Empty list when none exist.
    """

    def _list() -> list[str]:
        return [b.name for b in self.client.list_buckets()]

    return await asyncio.to_thread(_list)

remove_bucket async

remove_bucket(bucket: str | None = None) -> None

Delete an empty bucket.

Parameters:

Name Type Description Default
bucket str | None

Target bucket; defaults to default_bucket.

None

Raises:

Type Description
S3Error

When the bucket is missing or non-empty.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def remove_bucket(self, bucket: str | None = None) -> None:
    """Delete an empty bucket.

    Args:
        bucket (str | None): Target bucket; defaults to
            ``default_bucket``.

    Raises:
        S3Error: When the bucket is missing or non-empty.
    """
    target = self._bucket(bucket)
    await asyncio.to_thread(self.client.remove_bucket, target)

put_object async

put_object(
    key: str,
    data: bytes | BinaryIO,
    *,
    bucket: str | None = None,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
    length: int | None = None,
    part_size: int = 10 * 1024 * 1024,
) -> str

Upload an object.

Accepts both raw bytes and any binary file-like object (open("file", "rb"), BytesIO, UploadFile.file). For unknown-length streams pass length=-1 to enable multipart upload via part_size chunks.

Parameters:

Name Type Description Default
key str

Destination object key.

required
data bytes | BinaryIO

Payload. bytes is wrapped in a BytesIO; file-like objects are forwarded as-is.

required
bucket str | None

Override target bucket.

None
content_type str

MIME type. "application/octet-stream" by default.

'application/octet-stream'
metadata dict[str, str] | None

User metadata. Keys are stored under the x-amz-meta- namespace by minio automatically — pass plain names.

None
length int | None

Payload size in bytes. Required for unknown-length streams; computed automatically when data is bytes.

None
part_size int

Chunk size for multipart upload (when length is -1 or larger than 5 GiB). Must be at least 5 MiB. Default 10 MiB.

10 * 1024 * 1024

Returns:

Name Type Description
str str

ETag of the uploaded object (quotes stripped).

Raises:

Type Description
S3Error

When the upload fails (auth, network, bucket missing, content rejected).

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def put_object(
    self,
    key: str,
    data: bytes | BinaryIO,
    *,
    bucket: str | None = None,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
    length: int | None = None,
    part_size: int = 10 * 1024 * 1024,
) -> str:
    """Upload an object.

    Accepts both raw ``bytes`` and any binary file-like object
    (``open("file", "rb")``, ``BytesIO``, ``UploadFile.file``).
    For unknown-length streams pass ``length=-1`` to enable
    multipart upload via ``part_size`` chunks.

    Args:
        key (str): Destination object key.
        data (bytes | BinaryIO): Payload. ``bytes`` is wrapped
            in a ``BytesIO``; file-like objects are forwarded
            as-is.
        bucket (str | None): Override target bucket.
        content_type (str): MIME type. ``"application/octet-stream"``
            by default.
        metadata (dict[str, str] | None): User metadata. Keys
            are stored under the ``x-amz-meta-`` namespace by
            ``minio`` automatically — pass plain names.
        length (int | None): Payload size in bytes. Required
            for unknown-length streams; computed automatically
            when ``data`` is ``bytes``.
        part_size (int): Chunk size for multipart upload (when
            ``length`` is ``-1`` or larger than 5 GiB). Must be
            at least 5 MiB. Default 10 MiB.

    Returns:
        str: ETag of the uploaded object (quotes stripped).

    Raises:
        S3Error: When the upload fails (auth, network, bucket
            missing, content rejected).
    """
    target_bucket = self._bucket(bucket)
    if isinstance(data, bytes | bytearray):
        stream: BinaryIO = BytesIO(bytes(data))
        payload_length: int = len(data) if length is None else length
    else:
        stream = data
        if length is None:
            raise ValueError(
                "length must be provided for file-like data; pass -1 "
                "for unknown-length streams to trigger multipart upload"
            )
        payload_length = length

    def _put() -> str:
        result = self.client.put_object(
            target_bucket,
            key,
            stream,
            payload_length,
            content_type=content_type,
            metadata=metadata,  # type: ignore[arg-type]
            part_size=part_size,
        )
        return (result.etag or "").strip('"')

    return await asyncio.to_thread(_put)

fput_object async

fput_object(
    key: str,
    file_path: str | Path,
    *,
    bucket: str | None = None,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
) -> str

Upload a file from disk.

Parameters:

Name Type Description Default
key str

Destination object key.

required
file_path str | Path

Source path on disk.

required
bucket str | None

Override target bucket.

None
content_type str

MIME type.

'application/octet-stream'
metadata dict[str, str] | None

User metadata.

None

Returns:

Name Type Description
str str

ETag of the uploaded object (quotes stripped).

Raises:

Type Description
FileNotFoundError

When file_path does not exist.

S3Error

When the upload fails.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def fput_object(
    self,
    key: str,
    file_path: str | Path,
    *,
    bucket: str | None = None,
    content_type: str = "application/octet-stream",
    metadata: dict[str, str] | None = None,
) -> str:
    """Upload a file from disk.

    Args:
        key (str): Destination object key.
        file_path (str | Path): Source path on disk.
        bucket (str | None): Override target bucket.
        content_type (str): MIME type.
        metadata (dict[str, str] | None): User metadata.

    Returns:
        str: ETag of the uploaded object (quotes stripped).

    Raises:
        FileNotFoundError: When ``file_path`` does not exist.
        S3Error: When the upload fails.
    """
    target_bucket = self._bucket(bucket)
    path = Path(file_path)

    def _fput() -> str:
        result = self.client.fput_object(
            target_bucket,
            key,
            str(path),
            content_type=content_type,
            metadata=metadata,  # type: ignore[arg-type]
        )
        return (result.etag or "").strip('"')

    return await asyncio.to_thread(_fput)

get_object_bytes async

get_object_bytes(key: str, *, bucket: str | None = None) -> bytes

Download an object as bytes.

Suitable for small objects. For large payloads prefer :meth:stream_object to avoid loading everything in memory.

Parameters:

Name Type Description Default
key str

Object key.

required
bucket str | None

Override source bucket.

None

Returns:

Name Type Description
bytes bytes

Object payload.

Raises:

Type Description
S3Error

When the object is missing or the request fails.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def get_object_bytes(
    self,
    key: str,
    *,
    bucket: str | None = None,
) -> bytes:
    """Download an object as bytes.

    Suitable for small objects. For large payloads prefer
    :meth:`stream_object` to avoid loading everything in memory.

    Args:
        key (str): Object key.
        bucket (str | None): Override source bucket.

    Returns:
        bytes: Object payload.

    Raises:
        S3Error: When the object is missing or the request
            fails.
    """
    target = self._bucket(bucket)

    def _get() -> bytes:
        response = self.client.get_object(target, key)
        try:
            return response.read()
        finally:
            response.close()
            response.release_conn()

    return await asyncio.to_thread(_get)

fget_object async

fget_object(key: str, file_path: str | Path, *, bucket: str | None = None) -> Path

Download an object straight to disk.

Parameters:

Name Type Description Default
key str

Object key.

required
file_path str | Path

Destination path. Parent directories are created if missing.

required
bucket str | None

Override source bucket.

None

Returns:

Name Type Description
Path Path

The path the object was written to.

Raises:

Type Description
S3Error

When the object is missing or the request fails.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def fget_object(
    self,
    key: str,
    file_path: str | Path,
    *,
    bucket: str | None = None,
) -> Path:
    """Download an object straight to disk.

    Args:
        key (str): Object key.
        file_path (str | Path): Destination path. Parent
            directories are created if missing.
        bucket (str | None): Override source bucket.

    Returns:
        Path: The path the object was written to.

    Raises:
        S3Error: When the object is missing or the request
            fails.
    """
    target = self._bucket(bucket)
    path = Path(file_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    await asyncio.to_thread(
        self.client.fget_object,
        target,
        key,
        str(path),
    )
    return path

stream_object async

stream_object(
    key: str, *, bucket: str | None = None, chunk_size: int = 64 * 1024
) -> AsyncIterator[bytes]

Stream an object in fixed-size chunks.

The whole network read still runs in a worker thread — each chunk_size read is one asyncio.to_thread round-trip — but the event loop yields between chunks so other requests progress.

Parameters:

Name Type Description Default
key str

Object key.

required
bucket str | None

Override source bucket.

None
chunk_size int

Bytes per chunk. Default 64 KiB.

64 * 1024

Returns:

Type Description
AsyncIterator[bytes]

AsyncIterator[bytes]: Async generator yielding chunks

AsyncIterator[bytes]

until the stream ends.

Raises:

Type Description
S3Error

When the object is missing or the request fails.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def stream_object(
    self,
    key: str,
    *,
    bucket: str | None = None,
    chunk_size: int = 64 * 1024,
) -> AsyncIterator[bytes]:
    """Stream an object in fixed-size chunks.

    The whole network read still runs in a worker thread —
    each ``chunk_size`` read is one ``asyncio.to_thread``
    round-trip — but the event loop yields between chunks so
    other requests progress.

    Args:
        key (str): Object key.
        bucket (str | None): Override source bucket.
        chunk_size (int): Bytes per chunk. Default 64 KiB.

    Returns:
        AsyncIterator[bytes]: Async generator yielding chunks
        until the stream ends.

    Raises:
        S3Error: When the object is missing or the request
            fails.
    """
    target = self._bucket(bucket)
    response = await asyncio.to_thread(self.client.get_object, target, key)

    async def _iter() -> AsyncIterator[bytes]:
        try:
            while True:
                chunk = await asyncio.to_thread(response.read, chunk_size)
                if not chunk:
                    return
                yield chunk
        finally:
            await asyncio.to_thread(response.close)
            await asyncio.to_thread(response.release_conn)

    return _iter()

stat_object async

stat_object(key: str, *, bucket: str | None = None) -> ObjectStat

Fetch metadata for an object without downloading it.

Parameters:

Name Type Description Default
key str

Object key.

required
bucket str | None

Override source bucket.

None

Returns:

Name Type Description
ObjectStat ObjectStat

Subset of fields commonly needed by callers.

ObjectStat

Use .raw for the full minio Object.

Raises:

Type Description
S3Error

When the object is missing.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def stat_object(
    self,
    key: str,
    *,
    bucket: str | None = None,
) -> ObjectStat:
    """Fetch metadata for an object without downloading it.

    Args:
        key (str): Object key.
        bucket (str | None): Override source bucket.

    Returns:
        ObjectStat: Subset of fields commonly needed by callers.
        Use ``.raw`` for the full ``minio`` ``Object``.

    Raises:
        S3Error: When the object is missing.
    """
    target = self._bucket(bucket)
    raw = await asyncio.to_thread(self.client.stat_object, target, key)
    metadata: dict[str, str] = {
        k.removeprefix("x-amz-meta-"): v
        for k, v in (raw.metadata or {}).items()
        if k.lower().startswith("x-amz-meta-")
    }
    return ObjectStat(
        bucket=target,
        key=key,
        size=int(raw.size or 0),
        etag=(raw.etag or "").strip('"') or None,
        content_type=raw.content_type,
        last_modified=raw.last_modified,
        metadata=metadata,
        raw=raw,
    )

list_objects async

list_objects(
    prefix: str = "", *, bucket: str | None = None, recursive: bool = True
) -> list[str]

List object keys under a prefix.

Parameters:

Name Type Description Default
prefix str

Prefix filter. Empty string returns everything.

''
bucket str | None

Override source bucket.

None
recursive bool

Walk into pseudo-directories. False returns only the immediate level.

True

Returns:

Type Description
list[str]

list[str]: Object keys. Empty list when no matches —

list[str]

matching the SDK convention of "no rows is not an

list[str]

error".

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def list_objects(
    self,
    prefix: str = "",
    *,
    bucket: str | None = None,
    recursive: bool = True,
) -> list[str]:
    """List object keys under a prefix.

    Args:
        prefix (str): Prefix filter. Empty string returns
            everything.
        bucket (str | None): Override source bucket.
        recursive (bool): Walk into pseudo-directories.
            ``False`` returns only the immediate level.

    Returns:
        list[str]: Object keys. Empty list when no matches —
        matching the SDK convention of "no rows is not an
        error".
    """
    target = self._bucket(bucket)

    def _list() -> list[str]:
        return [
            obj.object_name or ""
            for obj in self.client.list_objects(
                target,
                prefix=prefix,
                recursive=recursive,
            )
        ]

    return await asyncio.to_thread(_list)

remove_object async

remove_object(
    key: str, *, bucket: str | None = None, version_id: str | None = None
) -> None

Delete an object (or a specific version).

Parameters:

Name Type Description Default
key str

Object key.

required
bucket str | None

Override target bucket.

None
version_id str | None

When the bucket has versioning enabled, the specific version to delete.

None

Raises:

Type Description
S3Error

When the delete fails for a reason other than "already gone" (deletes are idempotent on S3).

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def remove_object(
    self,
    key: str,
    *,
    bucket: str | None = None,
    version_id: str | None = None,
) -> None:
    """Delete an object (or a specific version).

    Args:
        key (str): Object key.
        bucket (str | None): Override target bucket.
        version_id (str | None): When the bucket has
            versioning enabled, the specific version to delete.

    Raises:
        S3Error: When the delete fails for a reason other than
            "already gone" (deletes are idempotent on S3).
    """
    target = self._bucket(bucket)
    await asyncio.to_thread(
        self.client.remove_object,
        target,
        key,
        version_id=version_id,
    )

copy_object async

copy_object(
    source_key: str,
    dest_key: str,
    *,
    source_bucket: str | None = None,
    dest_bucket: str | None = None,
) -> str

Copy an object inside the same store.

Parameters:

Name Type Description Default
source_key str

Source object key.

required
dest_key str

Destination object key.

required
source_bucket str | None

Source bucket; defaults to default_bucket.

None
dest_bucket str | None

Destination bucket; defaults to default_bucket.

None

Returns:

Name Type Description
str str

ETag of the copied object (quotes stripped).

Raises:

Type Description
S3Error

When the source is missing or the copy fails.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def copy_object(
    self,
    source_key: str,
    dest_key: str,
    *,
    source_bucket: str | None = None,
    dest_bucket: str | None = None,
) -> str:
    """Copy an object inside the same store.

    Args:
        source_key (str): Source object key.
        dest_key (str): Destination object key.
        source_bucket (str | None): Source bucket; defaults to
            ``default_bucket``.
        dest_bucket (str | None): Destination bucket; defaults
            to ``default_bucket``.

    Returns:
        str: ETag of the copied object (quotes stripped).

    Raises:
        S3Error: When the source is missing or the copy fails.
    """
    from minio.commonconfig import CopySource

    src_bucket = source_bucket or self.default_bucket
    dst_bucket = dest_bucket or self.default_bucket

    def _copy() -> str:
        result = self.client.copy_object(
            dst_bucket,
            dest_key,
            CopySource(src_bucket, source_key),
        )
        return (result.etag or "").strip('"')

    return await asyncio.to_thread(_copy)

presigned_get_url async

presigned_get_url(
    key: str, *, bucket: str | None = None, expires: timedelta = timedelta(hours=1)
) -> str

Generate a temporary download URL.

Parameters:

Name Type Description Default
key str

Object key.

required
bucket str | None

Override source bucket.

None
expires timedelta

URL lifetime. Maximum is 7 days (S3 hard limit).

timedelta(hours=1)

Returns:

Name Type Description
str str

Pre-signed HTTPS URL that anyone with the link

str

can GET until expiry.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def presigned_get_url(
    self,
    key: str,
    *,
    bucket: str | None = None,
    expires: timedelta = timedelta(hours=1),
) -> str:
    """Generate a temporary download URL.

    Args:
        key (str): Object key.
        bucket (str | None): Override source bucket.
        expires (timedelta): URL lifetime. Maximum is 7 days
            (S3 hard limit).

    Returns:
        str: Pre-signed HTTPS URL that anyone with the link
        can ``GET`` until expiry.
    """
    target = self._bucket(bucket)
    return await asyncio.to_thread(
        self.client.presigned_get_object,
        target,
        key,
        expires,
    )

presigned_put_url async

presigned_put_url(
    key: str, *, bucket: str | None = None, expires: timedelta = timedelta(minutes=15)
) -> str

Generate a temporary upload URL.

Lets the browser PUT directly to MinIO/S3 without the bytes touching the FastAPI process — ideal for large files.

Parameters:

Name Type Description Default
key str

Destination object key.

required
bucket str | None

Override target bucket.

None
expires timedelta

URL lifetime. Maximum is 7 days.

timedelta(minutes=15)

Returns:

Name Type Description
str str

Pre-signed HTTPS URL accepting a PUT with the

str

object body until expiry.

Source code in tempest_fastapi_sdk/storage/minio_client.py
async def presigned_put_url(
    self,
    key: str,
    *,
    bucket: str | None = None,
    expires: timedelta = timedelta(minutes=15),
) -> str:
    """Generate a temporary upload URL.

    Lets the browser ``PUT`` directly to MinIO/S3 without the
    bytes touching the FastAPI process — ideal for large
    files.

    Args:
        key (str): Destination object key.
        bucket (str | None): Override target bucket.
        expires (timedelta): URL lifetime. Maximum is 7 days.

    Returns:
        str: Pre-signed HTTPS URL accepting a ``PUT`` with the
        object body until expiry.
    """
    target = self._bucket(bucket)
    return await asyncio.to_thread(
        self.client.presigned_put_object,
        target,
        key,
        expires,
    )

ObjectStat dataclass

ObjectStat(
    bucket: str,
    key: str,
    size: int,
    etag: str | None,
    content_type: str | None,
    last_modified: datetime | None,
    metadata: dict[str, str],
    raw: Object,
)

Subset of object metadata returned by :meth:AsyncMinIOClient.stat_object.

The full minio.datatypes.Object instance is also reachable via the raw attribute when you need the long tail of fields (version id, owner, restoration state, etc.).

Attributes:

Name Type Description
bucket str

Bucket the object lives in.

key str

Object key (S3 path).

size int

Size in bytes.

etag str | None

Server-side ETag (quotes stripped).

content_type str | None

MIME type recorded at upload.

last_modified datetime | None

Last modification timestamp in UTC.

metadata dict[str, str]

User metadata keyed without the x-amz-meta- prefix.

raw Object

Underlying minio Object for advanced use (versioning id, owner, restore state, …).

Alembic hooks

reorder_base_columns_first

reorder_base_columns_first(
    context: MigrationContext, revision: Any, directives: list[Any]
) -> None

Reorder columns inside every autogenerated CreateTableOp.

SQLAlchemy stores columns in declaration order, which already matches what BaseModel defines (idupdated_at). Autogenerate normally honors that, but third-party mixins or class composition can flip the order in ways that leak into the migration. This hook normalizes it:

  • The four BaseModel columns (id, is_active, created_at, updated_at) come first, in that exact order.
  • Constraints stay where Alembic placed them (typically right after the columns they reference).
  • Every other column keeps its original relative position.

Parameters:

Name Type Description Default
context MigrationContext

The active Alembic context.

required
revision Any

The new revision identifier — unused.

required
directives list[Any]

The list of MigrationScript directives Alembic will render. Modified in-place.

required
Source code in tempest_fastapi_sdk/db/alembic_hooks.py
def reorder_base_columns_first(
    context: MigrationContext,
    revision: Any,
    directives: list[Any],
) -> None:
    """Reorder columns inside every autogenerated ``CreateTableOp``.

    SQLAlchemy stores columns in declaration order, which already
    matches what ``BaseModel`` defines (``id`` →
    ``updated_at``). Autogenerate normally honors that, but
    third-party mixins or class composition can flip the order in
    ways that leak into the migration. This hook normalizes it:

    * The four ``BaseModel`` columns (``id``, ``is_active``,
      ``created_at``, ``updated_at``) come first, in that exact
      order.
    * Constraints stay where Alembic placed them (typically right
      after the columns they reference).
    * Every other column keeps its original relative position.

    Args:
        context (MigrationContext): The active Alembic context.
        revision (Any): The new revision identifier — unused.
        directives (list[Any]): The list of ``MigrationScript``
            directives Alembic will render. Modified in-place.
    """
    del context, revision
    try:
        from alembic.operations import ops
    except ImportError:  # pragma: no cover
        return

    base_set = set(BASE_COLUMN_ORDER)

    def _walk(op_list: Sequence[Any]) -> None:
        for op in op_list:
            if isinstance(op, ops.CreateTableOp):
                op.columns = _reordered_columns(op.columns, base_set)
            # Recurse into nested directives (e.g. ModifyTableOps).
            for attr in ("ops", "upgrade_ops", "downgrade_ops"):
                nested: Any = getattr(op, attr, None)
                if nested is None:
                    continue
                if hasattr(nested, "ops"):
                    _walk(nested.ops)
                elif isinstance(nested, list):
                    _walk(nested)

    for directive in directives:
        if isinstance(directive, ops.MigrationScript):
            if directive.upgrade_ops is not None:
                _walk(directive.upgrade_ops.ops)
            if directive.downgrade_ops is not None:
                _walk(directive.downgrade_ops.ops)

compose_hooks

compose_hooks(*hooks: ProcessRevisionDirectives) -> ProcessRevisionDirectives

Chain multiple process_revision_directives hooks.

Hooks run in the order they're passed and share the same directives list — each can mutate it before the next sees it.

Parameters:

Name Type Description Default
*hooks ProcessRevisionDirectives

Callables matching the Alembic signature.

()

Returns:

Name Type Description
ProcessRevisionDirectives ProcessRevisionDirectives

A single callable Alembic can

ProcessRevisionDirectives

register.

Source code in tempest_fastapi_sdk/db/alembic_hooks.py
def compose_hooks(
    *hooks: ProcessRevisionDirectives,
) -> ProcessRevisionDirectives:
    """Chain multiple ``process_revision_directives`` hooks.

    Hooks run in the order they're passed and share the same
    ``directives`` list — each can mutate it before the next sees it.

    Args:
        *hooks (ProcessRevisionDirectives): Callables matching the
            Alembic signature.

    Returns:
        ProcessRevisionDirectives: A single callable Alembic can
        register.
    """

    def _combined(
        context: MigrationContext,
        revision: Any,
        directives: list[Any],
    ) -> None:
        for hook in hooks:
            hook(context, revision, directives)

    return _combined

Settings

tempest_fastapi_sdk.settings

BaseAppSettings

Bases: BaseSettings

Shared configuration for Settings classes across projects.

Provides the canonical pydantic-settings config block; concrete projects subclass this and add their domain-specific fields (database URLs, secrets, third-party keys, etc.).

The defaults:

  • env_file=".env" — load environment variables from a local .env file when present.
  • extra="ignore" — silently drop unexpected env vars instead of raising at startup.
  • case_sensitive=True — env var names are matched exactly.
  • frozen=True — settings are immutable after construction.
  • str_strip_whitespace=True — trim accidental whitespace around env values.
  • from_attributes=True — allow building from objects with attribute access (rarely needed for settings, but harmless).

Attributes:

Name Type Description
model_config SettingsConfigDict

The pydantic-settings configuration.

mixins

Composable settings mixins covering common service dependencies.

Each mixin is a fully-typed Pydantic model with sensible defaults so projects can opt in by listing the mixins they need alongside their own concrete Settings class:

class Settings(DatabaseSettings, RedisSettings, BaseAppSettings):
    ...

The mixins MUST be placed before :class:BaseAppSettings in the MRO so the latter's model_config wins. None of the mixins reads environment variables on their own — they rely on the consumer's BaseAppSettings configuration.

Every field carries title, description and examples so JSON-Schema consumers (FastAPI /docs, /redoc, IDE tooling, pydantic.model_json_schema()) render rich metadata out of the box.

ServerSettings

Bases: BaseSettings

HTTP server bind configuration.

LogSettings

Bases: BaseSettings

Structured logging configuration.

DatabaseSettings

Bases: BaseSettings

SQLAlchemy database connection configuration.

RedisSettings

Bases: BaseSettings

Redis connection configuration.

RabbitMQSettings

Bases: BaseSettings

RabbitMQ / FastStream broker configuration.

JWTSettings

Bases: BaseSettings

JWT signing and verification configuration.

CORSSettings

Bases: BaseSettings

CORS middleware configuration.

.. warning:: The default CORS_ORIGINS=["*"] is permissive on purpose so local development works out of the box. Never ship this default to production — set CORS_ORIGINS to the explicit list of trusted frontend origins. "*" is also incompatible with CORS_ALLOW_CREDENTIALS=True (browsers ignore credentialed requests sent to a wildcard origin).

EmailSettings

Bases: BaseSettings

SMTP / transactional email configuration.

Mirrors the constructor arguments of :class:tempest_fastapi_sdk.EmailUtils so a service can wire it up with EmailUtils(**settings.email_kwargs()).

UploadSettings

Bases: BaseSettings

File upload constraints.

Mirrors the constructor arguments of :class:tempest_fastapi_sdk.UploadUtils.

TokenSettings

Bases: BaseSettings

Shared-secret X-Token configuration.

Used by :func:tempest_fastapi_sdk.make_token_dependency for internal service-to-service authentication. Validation is performed with :func:hmac.compare_digest.

WebPushSettings

Bases: BaseSettings

Web Push / VAPID configuration.

Mirrors the constructor arguments of :class:tempest_fastapi_sdk.WebPushDispatcher.

TaskIQSettings

Bases: BaseSettings

TaskIQ broker / result backend configuration.

Use this when the TaskIQ broker is not the same RabbitMQ / Redis instance covered by :class:RabbitMQSettings / :class:RedisSettings.

AuthSettings

Bases: BaseSettings

Configuration for the bundled signup / activation / reset flows.

Consumed by :class:tempest_fastapi_sdk.auth.UserAuthService and :func:tempest_fastapi_sdk.make_auth_router. Each flag has a sensible production default; flip AUTH_AUTO_ACTIVATE or AUTH_RETURN_TOKEN_IN_RESPONSE only in dev / CI.

MinIOSettings

Bases: BaseSettings

MinIO / S3-compatible object storage configuration.

Consumed by :class:tempest_fastapi_sdk.AsyncMinIOClient. The same shape works for any S3-compatible target (AWS S3, MinIO, Backblaze B2, Cloudflare R2, Wasabi, DigitalOcean Spaces).

SessionSettings

Bases: BaseSettings

Server-side session cookie + storage configuration.

Consumed by :class:tempest_fastapi_sdk.SessionAuth, :class:tempest_fastapi_sdk.SessionMiddleware, and :func:tempest_fastapi_sdk.make_session_router. Defaults assume HTTPS in production (SESSION_COOKIE_SECURE=True) and a same-site SaaS topology (SESSION_COOKIE_SAMESITE="lax") — relax both only for local HTTP development.

WebSocketSettings

Bases: BaseSettings

WebSocket router configuration.

Consumed by :func:tempest_fastapi_sdk.make_websocket_router and :class:tempest_fastapi_sdk.WebSocketHub. Defaults are tuned for typical browser ↔ FastAPI deployments — heartbeats every 30s, drop after 60s without pong, five concurrent connections per user.


Admin

tempest_fastapi_sdk.admin

AdminSite

AdminSite(
    title: str = "Admin",
    *,
    index_subtitle: str = "Site administration",
    site_url: str | None = None,
)

Holds the set of :class:AdminModel configurations to expose.

Each project instantiates one site, registers its admin configurations, and passes the site to :func:make_admin_router. Sites are explicit (no auto-discovery) so the surface remains predictable across deployments.

Attributes:

Name Type Description
title str

Branding shown at the top of every admin page.

index_subtitle str

Optional subtitle for the dashboard.

site_url str | None

Optional "View site" link rendered in the admin header.

Initialize the site.

Parameters:

Name Type Description Default
title str

Branding text.

'Admin'
index_subtitle str

Dashboard subtitle.

'Site administration'
site_url str | None

Optional outbound link rendered in the admin header.

None
Source code in tempest_fastapi_sdk/admin/site.py
def __init__(
    self,
    title: str = "Admin",
    *,
    index_subtitle: str = "Site administration",
    site_url: str | None = None,
) -> None:
    """Initialize the site.

    Args:
        title (str): Branding text.
        index_subtitle (str): Dashboard subtitle.
        site_url (str | None): Optional outbound link rendered
            in the admin header.
    """
    self.title: str = title
    self.index_subtitle: str = index_subtitle
    self.site_url: str | None = site_url
    self._registry: dict[str, AdminModel[Any]] = {}

registry property

registry: dict[str, AdminModel[Any]]

Return a copy of the slug→admin mapping.

Returns:

Type Description
dict[str, AdminModel[Any]]

dict[str, AdminModel[Any]]: The current registry.

register

register(admin: AdminModel[Any]) -> AdminModel[Any]

Register admin against its model slug.

Example::

site.register(AdminModel(model=UserModel))

Parameters:

Name Type Description Default
admin AdminModel[Any]

The admin configuration instance.

required

Returns:

Type Description
AdminModel[Any]

AdminModel[Any]: The same instance (so the call can be

AdminModel[Any]

chained or assigned).

Raises:

Type Description
ValueError

When another admin is already registered under the same slug.

Source code in tempest_fastapi_sdk/admin/site.py
def register(self, admin: AdminModel[Any]) -> AdminModel[Any]:
    """Register ``admin`` against its model slug.

    Example::

        site.register(AdminModel(model=UserModel))

    Args:
        admin (AdminModel[Any]): The admin configuration instance.

    Returns:
        AdminModel[Any]: The same instance (so the call can be
        chained or assigned).

    Raises:
        ValueError: When another admin is already registered under
            the same slug.
    """
    slug = admin.get_slug()
    if slug in self._registry:
        existing = self._registry[slug]
        raise ValueError(
            f"AdminModel for slug {slug!r} already registered "
            f"({existing.model.__name__}); refusing to overwrite with "
            f"{admin.model.__name__}"
        )
    self._registry[slug] = admin
    return admin

unregister

unregister(slug: str) -> None

Remove a previously registered admin.

Parameters:

Name Type Description Default
slug str

The slug to drop.

required

Raises:

Type Description
KeyError

When no admin is registered under slug.

Source code in tempest_fastapi_sdk/admin/site.py
def unregister(self, slug: str) -> None:
    """Remove a previously registered admin.

    Args:
        slug (str): The slug to drop.

    Raises:
        KeyError: When no admin is registered under ``slug``.
    """
    del self._registry[slug]

get

get(slug: str) -> AdminModel[Any] | None

Return the admin registered under slug, or None.

Parameters:

Name Type Description Default
slug str

The admin slug.

required

Returns:

Type Description
AdminModel[Any] | None

AdminModel[Any] | None: The configuration instance.

Source code in tempest_fastapi_sdk/admin/site.py
def get(self, slug: str) -> AdminModel[Any] | None:
    """Return the admin registered under ``slug``, or ``None``.

    Args:
        slug (str): The admin slug.

    Returns:
        AdminModel[Any] | None: The configuration instance.
    """
    return self._registry.get(slug)

require

require(slug: str) -> AdminModel[Any]

Return the admin registered under slug or raise.

Parameters:

Name Type Description Default
slug str

The admin slug.

required

Returns:

Type Description
AdminModel[Any]

AdminModel[Any]: The configuration instance.

Raises:

Type Description
KeyError

When no admin matches the slug.

Source code in tempest_fastapi_sdk/admin/site.py
def require(self, slug: str) -> AdminModel[Any]:
    """Return the admin registered under ``slug`` or raise.

    Args:
        slug (str): The admin slug.

    Returns:
        AdminModel[Any]: The configuration instance.

    Raises:
        KeyError: When no admin matches the slug.
    """
    admin = self.get(slug)
    if admin is None:
        raise KeyError(f"No admin registered for slug {slug!r}")
    return admin

iter_models

iter_models() -> list[AdminModel[Any]]

Return registered admins ordered by display name.

Returns:

Type Description
list[AdminModel[Any]]

list[AdminModel[Any]]: Ordered admin instances.

Source code in tempest_fastapi_sdk/admin/site.py
def iter_models(self) -> list[AdminModel[Any]]:
    """Return registered admins ordered by display name.

    Returns:
        list[AdminModel[Any]]: Ordered admin instances.
    """
    return sorted(
        self._registry.values(),
        key=lambda admin: admin.get_verbose_name_plural().lower(),
    )

AdminModel

AdminModel(
    model: type[ModelT],
    *,
    list_display: Sequence[FieldRef] | None = None,
    list_filter: Sequence[FieldRef] = (),
    search_fields: Sequence[FieldRef] = (),
    readonly_fields: Sequence[FieldRef] = (),
    ordering: OrderRef | None = None,
    page_size: int = 25,
    identity_field: FieldRef = "id",
    repository_class: type[BaseRepository[Any]] | None = None,
    verbose_name: str | None = None,
    verbose_name_plural: str | None = None,
)

Bases: Generic[ModelT]

Declarative admin configuration for one SQLAlchemy model.

Instantiate once per managed model and pass it to :meth:AdminSite.register. Unlike Django's class-based ModelAdmin, this is a plain typed instance — the constructor signature is the contract, fields accept real SQLAlchemy column attributes (so typos surface in the editor, not at runtime), and there is no metaclass magic::

site.register(AdminModel(
    model=UserModel,
    list_display=[UserModel.email, UserModel.is_admin],
    search_fields=[UserModel.email],
    ordering=desc(UserModel.created_at),
))

Parameters:

Name Type Description Default
model type[ModelT]

The SQLAlchemy model class.

required
list_display Sequence[FieldRef] | None

Columns shown in the list view. None defaults to every column except the password hash.

None
list_filter Sequence[FieldRef]

Fields surfaced as filter dropdowns; matched via the repository's standard filter pipeline.

()
search_fields Sequence[FieldRef]

String columns searched with ILIKE %value% via the repository's name convention.

()
readonly_fields Sequence[FieldRef]

Fields locked in the detail view.

()
ordering OrderRef | None

Default ordering. Accepts a column (ascending), desc(column) / asc(column), or a string column name with an optional leading - for descending. None falls back to created_at descending.

None
page_size int

Default rows per page in the list view.

25
identity_field FieldRef

Column used to look up a single row from the detail URL. Defaults to "id" (UUID PK).

'id'
repository_class type[BaseRepository[Any]] | None

Concrete repository. None synthesizes an anonymous repository bound to :attr:model.

None
verbose_name str | None

Singular display name; defaults to the model name humanized.

None
verbose_name_plural str | None

Plural display name; defaults to verbose_name + "s".

None

Raises:

Type Description
TypeError

When model is not a subclass of :class:BaseModel, or when a field reference cannot be resolved to a column key.

Build and validate the configuration. See class docstring.

Source code in tempest_fastapi_sdk/admin/config.py
def __init__(
    self,
    model: type[ModelT],
    *,
    list_display: Sequence[FieldRef] | None = None,
    list_filter: Sequence[FieldRef] = (),
    search_fields: Sequence[FieldRef] = (),
    readonly_fields: Sequence[FieldRef] = (),
    ordering: OrderRef | None = None,
    page_size: int = 25,
    identity_field: FieldRef = "id",
    repository_class: type[BaseRepository[Any]] | None = None,
    verbose_name: str | None = None,
    verbose_name_plural: str | None = None,
) -> None:
    """Build and validate the configuration. See class docstring."""
    if not isinstance(model, type) or not issubclass(model, BaseModel):
        raise TypeError("AdminModel `model` must be a subclass of BaseModel")

    self.model: type[ModelT] = model
    self.list_display: list[str] | None = (
        None if list_display is None else _normalize_fields(list_display)
    )
    self.list_filter: list[str] = _normalize_fields(list_filter)
    self.search_fields: list[str] = _normalize_fields(search_fields)
    self.readonly_fields: list[str] = _normalize_fields(readonly_fields)
    self.order_key: str | None
    self.order_ascending: bool
    self.order_key, self.order_ascending = _normalize_ordering(ordering)
    self.page_size: int = page_size
    self.identity_field: str = _field_key(identity_field)
    self.repository_class: type[BaseRepository[Any]] | None = repository_class
    self.verbose_name: str | None = verbose_name
    self.verbose_name_plural: str | None = verbose_name_plural

get_verbose_name

get_verbose_name() -> str

Return the configured (or auto-derived) singular display name.

Returns:

Name Type Description
str str

The display name.

Source code in tempest_fastapi_sdk/admin/config.py
def get_verbose_name(self) -> str:
    """Return the configured (or auto-derived) singular display name.

    Returns:
        str: The display name.
    """
    if self.verbose_name:
        return self.verbose_name
    return _humanize(self.model.__name__.removesuffix("Model"))

get_verbose_name_plural

get_verbose_name_plural() -> str

Return the configured (or auto-derived) plural display name.

Returns:

Name Type Description
str str

The plural display name.

Source code in tempest_fastapi_sdk/admin/config.py
def get_verbose_name_plural(self) -> str:
    """Return the configured (or auto-derived) plural display name.

    Returns:
        str: The plural display name.
    """
    if self.verbose_name_plural:
        return self.verbose_name_plural
    return f"{self.get_verbose_name()}s"

get_slug

get_slug() -> str

Return the URL slug under which the model is exposed.

Defaults to __tablename__ so admin URLs and DB tables stay in sync.

Returns:

Name Type Description
str str

The slug.

Source code in tempest_fastapi_sdk/admin/config.py
def get_slug(self) -> str:
    """Return the URL slug under which the model is exposed.

    Defaults to ``__tablename__`` so admin URLs and DB tables
    stay in sync.

    Returns:
        str: The slug.
    """
    return self.model.__tablename__

column_names

column_names() -> list[str]

Return every mapped column name on :attr:model.

Returns:

Type Description
list[str]

list[str]: Column keys in declaration order.

Source code in tempest_fastapi_sdk/admin/config.py
def column_names(self) -> list[str]:
    """Return every mapped column name on :attr:`model`.

    Returns:
        list[str]: Column keys in declaration order.
    """
    return [attr.key for attr in inspect(self.model).mapper.column_attrs]

resolved_list_display

resolved_list_display() -> list[str]

Return the effective list_display column list.

Defaults to every column except hashed_password when unconfigured.

Returns:

Type Description
list[str]

list[str]: The list of columns to render.

Source code in tempest_fastapi_sdk/admin/config.py
def resolved_list_display(self) -> list[str]:
    """Return the effective ``list_display`` column list.

    Defaults to every column except ``hashed_password`` when
    unconfigured.

    Returns:
        list[str]: The list of columns to render.
    """
    if self.list_display is not None:
        return list(self.list_display)
    return [name for name in self.column_names() if name not in {"hashed_password"}]

build_repository

build_repository(session: AsyncSession) -> BaseRepository[ModelT]

Instantiate the repository for session.

Uses :attr:repository_class when provided (typically a subclass adding custom queries), otherwise instantiates :class:BaseRepository directly with model=self.model.

Parameters:

Name Type Description Default
session AsyncSession

The DB session to bind.

required

Returns:

Type Description
BaseRepository[ModelT]

BaseRepository[ModelT]: A repository ready to use.

Source code in tempest_fastapi_sdk/admin/config.py
def build_repository(self, session: AsyncSession) -> BaseRepository[ModelT]:
    """Instantiate the repository for ``session``.

    Uses :attr:`repository_class` when provided (typically a subclass
    adding custom queries), otherwise instantiates :class:`BaseRepository`
    directly with ``model=self.model``.

    Args:
        session (AsyncSession): The DB session to bind.

    Returns:
        BaseRepository[ModelT]: A repository ready to use.
    """
    if self.repository_class is not None:
        factory = cast(
            "Callable[[AsyncSession], BaseRepository[ModelT]]",
            self.repository_class,
        )
        return factory(session)
    return BaseRepository(session, model=self.model)

AdminAuthBackend

Bases: ABC

Abstract base for admin authentication.

Implementations receive a session-bound async DB session per login attempt; the default :class:UserModelAuthBackend queries a :class:BaseUserModel subclass and enforces is_admin=True. Custom backends can use the same protocol to integrate LDAP, OAuth, IAM tokens, etc.

authenticate abstractmethod async

authenticate(session: AsyncSession, *, identifier: str, password: str) -> Any

Verify credentials and return the authenticated principal.

Parameters:

Name Type Description Default
session AsyncSession

A live DB session.

required
identifier str

The login identifier (typically email).

required
password str

The plaintext password.

required

Returns:

Name Type Description
Any Any

The authenticated principal. The admin router calls

Any

meth:principal_id on the return value to derive the

Any

session payload. Typically a :class:BaseUserModel row.

Raises:

Type Description
AdminAuthError

On any rejection (unknown user, wrong password, not an admin, disabled account).

Source code in tempest_fastapi_sdk/admin/auth.py
@abstractmethod
async def authenticate(
    self,
    session: AsyncSession,
    *,
    identifier: str,
    password: str,
) -> Any:
    """Verify credentials and return the authenticated principal.

    Args:
        session (AsyncSession): A live DB session.
        identifier (str): The login identifier (typically email).
        password (str): The plaintext password.

    Returns:
        Any: The authenticated principal. The admin router calls
        :meth:`principal_id` on the return value to derive the
        session payload. Typically a :class:`BaseUserModel` row.

    Raises:
        AdminAuthError: On any rejection (unknown user, wrong
            password, not an admin, disabled account).
    """

load_principal abstractmethod async

load_principal(session: AsyncSession, principal_id: str) -> Any | None

Reload the principal from storage given its ID.

Called on every request once the session cookie has been validated. Returning None invalidates the session.

Parameters:

Name Type Description Default
session AsyncSession

A live DB session.

required
principal_id str

The identifier produced by :meth:principal_id at login.

required

Returns:

Type Description
Any | None

Any | None: The reloaded principal, or None when it

Any | None

no longer exists or no longer has admin access.

Source code in tempest_fastapi_sdk/admin/auth.py
@abstractmethod
async def load_principal(
    self,
    session: AsyncSession,
    principal_id: str,
) -> Any | None:
    """Reload the principal from storage given its ID.

    Called on every request once the session cookie has been
    validated. Returning ``None`` invalidates the session.

    Args:
        session (AsyncSession): A live DB session.
        principal_id (str): The identifier produced by
            :meth:`principal_id` at login.

    Returns:
        Any | None: The reloaded principal, or ``None`` when it
        no longer exists or no longer has admin access.
    """

principal_id abstractmethod

principal_id(principal: Any) -> str

Return a stable identifier for the authenticated principal.

Parameters:

Name Type Description Default
principal Any

The value returned by :meth:authenticate.

required

Returns:

Name Type Description
str str

The identifier serialized into the session cookie.

Source code in tempest_fastapi_sdk/admin/auth.py
@abstractmethod
def principal_id(self, principal: Any) -> str:
    """Return a stable identifier for the authenticated principal.

    Args:
        principal (Any): The value returned by :meth:`authenticate`.

    Returns:
        str: The identifier serialized into the session cookie.
    """

display_name

display_name(principal: Any) -> str

Return a human-readable label for the principal.

Defaults to the principal's email attribute (or its repr when missing); override for richer labels.

Parameters:

Name Type Description Default
principal Any

The principal.

required

Returns:

Name Type Description
str str

A label suitable for the admin header bar.

Source code in tempest_fastapi_sdk/admin/auth.py
def display_name(self, principal: Any) -> str:
    """Return a human-readable label for the principal.

    Defaults to the principal's ``email`` attribute (or its repr
    when missing); override for richer labels.

    Args:
        principal (Any): The principal.

    Returns:
        str: A label suitable for the admin header bar.
    """
    email = getattr(principal, "email", None)
    return str(email) if email else repr(principal)

UserModelAuthBackend

UserModelAuthBackend(user_model: type[BaseUserModel])

Bases: AdminAuthBackend

Default backend backed by :class:BaseUserModel.

Authenticates by selecting the row whose email matches the inbound identifier (case-insensitive), verifying the password via :class:tempest_fastapi_sdk.PasswordUtils and enforcing both is_admin=True and is_active=True. The :attr:last_login_at column is stamped on every successful login.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

The concrete model class. Must be a subclass of :class:BaseUserModel.

required

Initialize the backend.

Parameters:

Name Type Description Default
user_model type[BaseUserModel]

The user model to query.

required

Raises:

Type Description
TypeError

When user_model is not a subclass of :class:BaseUserModel.

Source code in tempest_fastapi_sdk/admin/auth.py
def __init__(self, user_model: type[BaseUserModel]) -> None:
    """Initialize the backend.

    Args:
        user_model (type[BaseUserModel]): The user model to query.

    Raises:
        TypeError: When ``user_model`` is not a subclass of
            :class:`BaseUserModel`.
    """
    if not isinstance(user_model, type) or not issubclass(
        user_model, BaseUserModel
    ):
        raise TypeError(
            "user_model must be a subclass of BaseUserModel",
        )
    self.user_model: type[BaseUserModel] = user_model

authenticate async

authenticate(session: AsyncSession, *, identifier: str, password: str) -> BaseUserModel

Verify credentials against the configured user model.

Parameters:

Name Type Description Default
session AsyncSession

A live DB session.

required
identifier str

The user's email.

required
password str

The plaintext password.

required

Returns:

Name Type Description
BaseUserModel BaseUserModel

The authenticated row with

BaseUserModel

attr:last_login_at already stamped.

Raises:

Type Description
AdminAuthError

On any rejection.

Source code in tempest_fastapi_sdk/admin/auth.py
async def authenticate(
    self,
    session: AsyncSession,
    *,
    identifier: str,
    password: str,
) -> BaseUserModel:
    """Verify credentials against the configured user model.

    Args:
        session (AsyncSession): A live DB session.
        identifier (str): The user's email.
        password (str): The plaintext password.

    Returns:
        BaseUserModel: The authenticated row with
        :attr:`last_login_at` already stamped.

    Raises:
        AdminAuthError: On any rejection.
    """
    normalized = self.user_model.normalize_email(identifier)
    result = await session.execute(
        select(self.user_model).where(self.user_model.email == normalized)
    )
    user = result.scalar_one_or_none()
    if user is None:
        raise AdminAuthError("Invalid credentials")
    if not user.is_active:
        raise AdminAuthError("Account disabled")
    if not user.is_admin:
        raise AdminAuthError("This account is not authorized for /admin")
    if not user.check_password(password):
        raise AdminAuthError("Invalid credentials")
    user.last_login_at = utcnow()
    await session.commit()
    await session.refresh(user)
    return user

load_principal async

load_principal(session: AsyncSession, principal_id: str) -> BaseUserModel | None

Reload the user by ID, ensuring they still qualify for admin.

Returns None when the user no longer exists, has been soft-deleted, or had is_admin revoked.

Parameters:

Name Type Description Default
session AsyncSession

A live DB session.

required
principal_id str

The UUID hex string from the cookie.

required

Returns:

Type Description
BaseUserModel | None

BaseUserModel | None: The user row, or None.

Source code in tempest_fastapi_sdk/admin/auth.py
async def load_principal(
    self,
    session: AsyncSession,
    principal_id: str,
) -> BaseUserModel | None:
    """Reload the user by ID, ensuring they still qualify for admin.

    Returns ``None`` when the user no longer exists, has been
    soft-deleted, or had ``is_admin`` revoked.

    Args:
        session (AsyncSession): A live DB session.
        principal_id (str): The UUID hex string from the cookie.

    Returns:
        BaseUserModel | None: The user row, or ``None``.
    """
    try:
        uid = UUID(principal_id)
    except (ValueError, TypeError):
        return None
    result = await session.execute(
        select(self.user_model).where(self.user_model.id == uid)
    )
    user = result.scalar_one_or_none()
    if user is None or not user.is_active or not user.is_admin:
        return None
    return user

principal_id

principal_id(principal: Any) -> str

Return the id UUID hex for serialization.

Parameters:

Name Type Description Default
principal Any

A :class:BaseUserModel instance.

required

Returns:

Name Type Description
str str

The UUID as str.

Source code in tempest_fastapi_sdk/admin/auth.py
def principal_id(self, principal: Any) -> str:
    """Return the ``id`` UUID hex for serialization.

    Args:
        principal (Any): A :class:`BaseUserModel` instance.

    Returns:
        str: The UUID as ``str``.
    """
    return str(principal.id)

make_admin_router

make_admin_router(
    site: AdminSite,
    *,
    db: AsyncDatabaseManager,
    auth_backend: AdminAuthBackend,
    secret_key: str,
    prefix: str = "/admin",
    session_store: SessionStore | None = None,
    cookie_secure: bool = True,
) -> APIRouter

Build the FastAPI router that mounts the admin site.

Routes attached:

  • GET {prefix}/login — login form.
  • POST {prefix}/login — login submit.
  • POST {prefix}/logout — clear session + redirect.
  • GET {prefix}/ — dashboard listing registered admins.
  • GET {prefix}/m/{slug}/ — list view (paginated).
  • GET {prefix}/m/{slug}/{identity} — detail view.
  • Static files under {prefix}/static named admin_static.

Parameters:

Name Type Description Default
site AdminSite

The configured registry.

required
db AsyncDatabaseManager

Active DB manager (used for both sessions and the readiness check).

required
auth_backend AdminAuthBackend

Backend resolving login credentials to a principal.

required
secret_key str

Secret used to sign the session cookie. 32 bytes minimum.

required
prefix str

URL prefix; defaults to "/admin".

'/admin'
session_store SessionStore | None

Override the default :class:SignedCookieSessionStore.

None
cookie_secure bool

Set the Secure flag on cookies. Default True; disable only for local HTTP dev.

True

Returns:

Name Type Description
APIRouter APIRouter

A router ready to attach via app.include_router.

Source code in tempest_fastapi_sdk/admin/router.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
def make_admin_router(
    site: AdminSite,
    *,
    db: AsyncDatabaseManager,
    auth_backend: AdminAuthBackend,
    secret_key: str,
    prefix: str = "/admin",
    session_store: SessionStore | None = None,
    cookie_secure: bool = True,
) -> APIRouter:
    """Build the FastAPI router that mounts the admin site.

    Routes attached:

    * ``GET  {prefix}/login`` — login form.
    * ``POST {prefix}/login`` — login submit.
    * ``POST {prefix}/logout`` — clear session + redirect.
    * ``GET  {prefix}/`` — dashboard listing registered admins.
    * ``GET  {prefix}/m/{slug}/`` — list view (paginated).
    * ``GET  {prefix}/m/{slug}/{identity}`` — detail view.
    * Static files under ``{prefix}/static`` named ``admin_static``.

    Args:
        site (AdminSite): The configured registry.
        db (AsyncDatabaseManager): Active DB manager (used for both
            sessions and the readiness check).
        auth_backend (AdminAuthBackend): Backend resolving login
            credentials to a principal.
        secret_key (str): Secret used to sign the session cookie.
            32 bytes minimum.
        prefix (str): URL prefix; defaults to ``"/admin"``.
        session_store (SessionStore | None): Override the default
            :class:`SignedCookieSessionStore`.
        cookie_secure (bool): Set the ``Secure`` flag on cookies.
            Default ``True``; disable only for local HTTP dev.

    Returns:
        APIRouter: A router ready to attach via ``app.include_router``.
    """
    try:
        from fastapi.templating import Jinja2Templates
    except ImportError as exc:
        raise ImportError(
            "Admin requires the [admin] extra. "
            "Install with `pip install tempest-fastapi-sdk[admin]`."
        ) from exc

    store: SessionStore = session_store or SignedCookieSessionStore(
        secret_key,
        secure=cookie_secure,
        path=prefix,
    )
    templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
    templates.env.filters["pluralize"] = _pluralize

    router = APIRouter(prefix=prefix, include_in_schema=False)

    @router.get("/static/{path:path}", name="admin_static")
    async def static_files(path: str) -> FileResponse:
        """Serve a file from the admin static directory.

        Args:
            path (str): Relative path under the static directory.

        Returns:
            FileResponse: The requested file.

        Raises:
            HTTPException: ``404`` when the file does not exist or
                escapes the static root.
        """
        target = (_STATIC_DIR / path).resolve()
        if _STATIC_DIR not in target.parents and target != _STATIC_DIR:
            raise HTTPException(status.HTTP_404_NOT_FOUND, "not found")
        if not target.is_file():
            raise HTTPException(status.HTTP_404_NOT_FOUND, "not found")
        return FileResponse(target)

    _db_session = db.session_dependency

    async def _require_session(request: Request) -> AdminSession:
        """Return the active session or redirect to login.

        Args:
            request (Request): The inbound request.

        Returns:
            AdminSession: The validated session payload.

        Raises:
            HTTPException: ``303`` redirect to ``/login`` when absent.
        """
        session = store.load(request)
        if session is None:
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                detail="login required",
                headers={"location": f"{prefix}/login"},
            )
        return session

    async def _resolve_principal(
        request: Request,
        db_session: AsyncSession,
        admin_session: AdminSession,
    ) -> Any:
        """Reload the admin principal or redirect to login.

        Args:
            request (Request): The inbound request.
            db_session (AsyncSession): The DB session.
            admin_session (AdminSession): The validated cookie.

        Returns:
            Any: The reloaded principal.

        Raises:
            HTTPException: ``303`` redirect when the principal is gone.
        """
        principal = await auth_backend.load_principal(
            db_session, admin_session.principal_id
        )
        if principal is None:
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                detail="login required",
                headers={"location": f"{prefix}/login"},
            )
        return principal

    def _render(
        request: Request,
        template: str,
        context: dict[str, Any],
        *,
        status_code: int = 200,
    ) -> HTMLResponse:
        """Render ``template`` with the SDK's shared context.

        Args:
            request (Request): The inbound request (required by Jinja).
            template (str): The template filename.
            context (dict[str, Any]): Extra template variables.
            status_code (int): HTTP status code.

        Returns:
            HTMLResponse: The rendered response.
        """
        context.setdefault("site", site)
        context.setdefault("messages", [])
        context.setdefault("static_url", f"{prefix}/static")
        return templates.TemplateResponse(
            request,
            template,
            context,
            status_code=status_code,
        )

    @router.get("/login", name="admin_login_form")
    async def login_form(
        request: Request,
        next: str | None = None,
    ) -> HTMLResponse:
        """Render the login form (or redirect when already signed in).

        Args:
            request (Request): The inbound request.
            next (str | None): Optional post-login destination.

        Returns:
            HTMLResponse: The rendered login template.
        """
        if store.load(request) is not None:
            return _render(request, "login.html", {"user": None})
        return _render(
            request,
            "login.html",
            {"user": None, "error": None, "next": next or ""},
        )

    @router.post("/login", name="admin_login")
    async def login_submit(
        request: Request,
        identifier: str = Form(...),
        password: str = Form(...),
        db_session: AsyncSession = Depends(_db_session),
    ) -> Response:
        """Verify credentials and issue the session cookie.

        Args:
            request (Request): The inbound request.
            identifier (str): The email submitted in the form.
            password (str): The submitted password.
            db_session (AsyncSession): The DB session.

        Returns:
            Response: Redirect to the dashboard on success; rendered
            login form with an error message on failure.
        """
        try:
            principal = await auth_backend.authenticate(
                db_session,
                identifier=identifier,
                password=password,
            )
        except AdminAuthError as exc:
            return _render(
                request,
                "login.html",
                {"user": None, "error": exc.message},
                status_code=exc.status_code,
            )
        session = AdminSession(
            principal_id=auth_backend.principal_id(principal),
            issued_at=datetime.now(tz=UTC).timestamp(),
            csrf_token=secrets.token_urlsafe(32),
        )
        response = RedirectResponse(
            url=f"{prefix}/",
            status_code=status.HTTP_303_SEE_OTHER,
        )
        store.save(response, session)
        return response

    @router.post("/logout", name="admin_logout")
    async def logout(
        request: Request,
        csrf_token: str = Form(...),
    ) -> Response:
        """Clear the active session.

        Args:
            request (Request): The inbound request.
            csrf_token (str): CSRF token submitted with the form.

        Returns:
            Response: Redirect to the login page.

        Raises:
            HTTPException: ``403`` when the CSRF token mismatches.
        """
        session = store.load(request)
        if session is not None and not secrets.compare_digest(
            session.csrf_token, csrf_token
        ):
            raise HTTPException(status.HTTP_403_FORBIDDEN, "csrf token mismatch")
        response = RedirectResponse(
            url=f"{prefix}/login",
            status_code=status.HTTP_303_SEE_OTHER,
        )
        store.clear(response)
        return response

    @router.get("/", name="admin_index")
    async def dashboard(
        request: Request,
        db_session: AsyncSession = Depends(_db_session),
        session: AdminSession = Depends(_require_session),
    ) -> HTMLResponse:
        """Render the dashboard listing registered admins.

        Args:
            request (Request): The inbound request.
            db_session (AsyncSession): The DB session.
            session (AdminSession): The validated session.

        Returns:
            HTMLResponse: The dashboard template.
        """
        principal = await _resolve_principal(request, db_session, session)
        return _render(
            request,
            "dashboard.html",
            {
                "user": principal,
                "session": session,
                "user_display": auth_backend.display_name(principal),
                "admins": site.iter_models(),
            },
        )

    @router.get("/m/{slug}/", name="admin_list")
    async def list_view(
        request: Request,
        slug: str,
        page: int = 1,
        q: str = "",
        db_session: AsyncSession = Depends(_db_session),
        session: AdminSession = Depends(_require_session),
    ) -> HTMLResponse:
        """Render the list view for a registered admin.

        Args:
            request (Request): The inbound request.
            slug (str): The admin slug from the URL.
            page (int): 1-indexed page number.
            q (str): Free-text search term.
            db_session (AsyncSession): The DB session.
            session (AdminSession): The validated session.

        Returns:
            HTMLResponse: The list template.
        """
        admin = site.get(slug)
        if admin is None:
            raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown admin")
        principal = await _resolve_principal(request, db_session, session)

        repository = admin.build_repository(db_session)

        filters: dict[str, Any] = {}
        active_filters: dict[str, str] = {}
        for field in admin.list_filter:
            value = request.query_params.get(f"filter_{field}")
            if value:
                active_filters[field] = value
                filters[field] = _coerce_filter_value(value)
        if q:
            for field in admin.search_fields:
                filters[field] = q

        page = max(1, page)
        size = max(1, admin.page_size)
        result = await repository.paginate(
            filters=filters,
            page=page,
            page_size=size,
            order_by=admin.order_key,
            ascending=admin.order_ascending,
        )

        columns = admin.resolved_list_display()
        rows = [
            _RowView(instance, columns, admin.identity_field)
            for instance in result["items"]
        ]

        query_params: dict[str, str] = {}
        if q:
            query_params["q"] = q
        for field, value in active_filters.items():
            query_params[f"filter_{field}"] = value

        pagination = _Pagination(
            page=result["page"],
            size=result["page_size"],
            total=result["total"],
            query_params=query_params,
        )

        return _render(
            request,
            "list.html",
            {
                "user": principal,
                "session": session,
                "user_display": auth_backend.display_name(principal),
                "admin": admin,
                "columns": columns,
                "rows": rows,
                "pagination": pagination,
                "query": {"q": q},
                "filter_options": {
                    field: _filter_options(admin, field, active_filters.get(field))
                    for field in admin.list_filter
                },
            },
        )

    @router.get("/m/{slug}/{identity}", name="admin_detail")
    async def detail_view(
        request: Request,
        slug: str,
        identity: str,
        db_session: AsyncSession = Depends(_db_session),
        session: AdminSession = Depends(_require_session),
    ) -> HTMLResponse:
        """Render the detail (read-only) view for one row.

        Args:
            request (Request): The inbound request.
            slug (str): The admin slug.
            identity (str): The primary-key value from the URL.
            db_session (AsyncSession): The DB session.
            session (AdminSession): The validated session.

        Returns:
            HTMLResponse: The detail template.
        """
        admin = site.get(slug)
        if admin is None:
            raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown admin")
        principal = await _resolve_principal(request, db_session, session)
        repository = admin.build_repository(db_session)
        try:
            from uuid import UUID

            value: Any = UUID(identity)
        except (ValueError, TypeError):
            value = identity

        instance = await repository.get_or_none({admin.identity_field: value})
        if instance is None:
            raise HTTPException(status.HTTP_404_NOT_FOUND, "record not found")

        columns = admin.column_names()
        readonly = set(admin.readonly_fields)
        fields: list[tuple[str, Any]] = []
        for column in columns:
            if column == "hashed_password":
                continue
            raw_value = getattr(instance, column, None)
            fields.append((column, raw_value))
        return _render(
            request,
            "detail.html",
            {
                "user": principal,
                "session": session,
                "user_display": auth_backend.display_name(principal),
                "admin": admin,
                "identity": identity,
                "fields": fields,
                "readonly": readonly,
            },
        )

    return router

Cache

AsyncRedisManager

AsyncRedisManager(url: str, *, decode_responses: bool = True, **client_kwargs: Any)

Manage the lifecycle of a single async Redis client.

Mirrors the public surface of :class:tempest_fastapi_sdk.AsyncDatabaseManager so application bootstrapping stays uniform across backends. The actual client is created on first :meth:connect call; in-process callers can use :meth:get_client_context from a FastAPI dependency or any async context manager.

Attributes:

Name Type Description
url str

The Redis connection URL.

decode_responses bool

Whether the underlying client decodes responses to str.

Initialize the manager (no connection opened yet).

Parameters:

Name Type Description Default
url str

The Redis URL (redis://... or rediss://... for TLS).

required
decode_responses bool

Whether to decode bytes to strings on every command.

True
**client_kwargs Any

Extra kwargs forwarded to redis.asyncio.Redis.from_url.

{}
Source code in tempest_fastapi_sdk/cache/redis_manager.py
def __init__(
    self,
    url: str,
    *,
    decode_responses: bool = True,
    **client_kwargs: Any,
) -> None:
    """Initialize the manager (no connection opened yet).

    Args:
        url (str): The Redis URL (``redis://...`` or
            ``rediss://...`` for TLS).
        decode_responses (bool): Whether to decode bytes to
            strings on every command.
        **client_kwargs (Any): Extra kwargs forwarded to
            ``redis.asyncio.Redis.from_url``.
    """
    self.url: str = url
    self.decode_responses: bool = decode_responses
    self._client_kwargs: dict[str, Any] = client_kwargs
    self._client: Redis | None = None

client property

client: Redis

Return the live client.

Returns:

Name Type Description
Redis Redis

The connected Redis client.

Raises:

Type Description
RuntimeError

When :meth:connect was not called yet.

connect async

connect() -> None

Open the underlying Redis client.

Safe to call multiple times — subsequent calls are no-ops while the same client is alive.

Source code in tempest_fastapi_sdk/cache/redis_manager.py
async def connect(self) -> None:
    """Open the underlying Redis client.

    Safe to call multiple times — subsequent calls are no-ops
    while the same client is alive.
    """
    if self._client is not None:
        return
    redis_async = _require_redis()
    self._client = redis_async.Redis.from_url(
        self.url,
        decode_responses=self.decode_responses,
        **self._client_kwargs,
    )

disconnect async

disconnect() -> None

Close the underlying client and release its connection pool.

Source code in tempest_fastapi_sdk/cache/redis_manager.py
async def disconnect(self) -> None:
    """Close the underlying client and release its connection pool."""
    if self._client is None:
        return
    await self._client.aclose()
    self._client = None

get_client_context async

get_client_context() -> AsyncIterator[Redis]

Yield the live client inside an async with block.

The manager owns the lifecycle — exiting the context does NOT close the underlying client. Use :meth:disconnect during application shutdown instead.

Yields:

Name Type Description
Redis AsyncIterator[Redis]

The connected client.

Source code in tempest_fastapi_sdk/cache/redis_manager.py
@asynccontextmanager
async def get_client_context(self) -> AsyncIterator[Redis]:
    """Yield the live client inside an ``async with`` block.

    The manager owns the lifecycle — exiting the context does
    NOT close the underlying client. Use :meth:`disconnect`
    during application shutdown instead.

    Yields:
        Redis: The connected client.
    """
    yield self.client

client_dependency async

client_dependency() -> AsyncIterator[Redis]

Async generator dependency suitable for FastAPI Depends.

Yields:

Name Type Description
Redis AsyncIterator[Redis]

The connected client.

Source code in tempest_fastapi_sdk/cache/redis_manager.py
async def client_dependency(self) -> AsyncIterator[Redis]:
    """Async generator dependency suitable for FastAPI ``Depends``.

    Yields:
        Redis: The connected client.
    """
    yield self.client

health_check async

health_check() -> bool

Return True when PING succeeds.

Errors are caught and logged at WARNING level — the health router treats exceptions as a failed check.

Returns:

Name Type Description
bool bool

True when the server responded with PONG.

Source code in tempest_fastapi_sdk/cache/redis_manager.py
async def health_check(self) -> bool:
    """Return ``True`` when ``PING`` succeeds.

    Errors are caught and logged at WARNING level — the health
    router treats exceptions as a failed check.

    Returns:
        bool: ``True`` when the server responded with ``PONG``.
    """
    try:
        result: Any = await self.client.ping()  # type: ignore[misc]
    except Exception as exc:
        logger.warning("Redis health check failed: %s", exc)
        return False
    return bool(result)

Server-Sent Events

tempest_fastapi_sdk.sse

EventStream

EventStream(*, heartbeat_seconds: float | None = 15.0)

Async in-memory queue feeding one SSE HTTP connection.

A handler builds one stream per client request, publish-es events from anywhere in the application (background tasks, websockets, dependency callbacks), and passes :meth:stream to :func:sse_response. A None enqueued by :meth:close terminates the iteration so the response completes cleanly.

Heartbeats are emitted as SSE comments (: keepalive lines) when the queue stays empty for longer than heartbeat_seconds; this keeps load-balancers from closing idle TCP connections.

Attributes:

Name Type Description
heartbeat_seconds float | None

Idle interval that triggers a comment heartbeat. None disables heartbeats.

Initialize the stream.

Parameters:

Name Type Description Default
heartbeat_seconds float | None

Idle interval before a comment heartbeat is emitted.

15.0
Source code in tempest_fastapi_sdk/sse/event_stream.py
def __init__(self, *, heartbeat_seconds: float | None = 15.0) -> None:
    """Initialize the stream.

    Args:
        heartbeat_seconds (float | None): Idle interval before
            a comment heartbeat is emitted.
    """
    self._queue: asyncio.Queue[ServerSentEvent | None] = asyncio.Queue()
    self.heartbeat_seconds: float | None = heartbeat_seconds

publish async

publish(
    data: Any = "",
    *,
    event: str | None = None,
    id: str | None = None,
    retry: int | None = None,
) -> None

Enqueue a new event for delivery.

Parameters:

Name Type Description Default
data Any

The payload (string, bytes or JSON-serializable).

''
event str | None

Optional event name.

None
id str | None

Optional Last-Event-ID.

None
retry int | None

Optional reconnect hint in milliseconds.

None
Source code in tempest_fastapi_sdk/sse/event_stream.py
async def publish(
    self,
    data: Any = "",
    *,
    event: str | None = None,
    id: str | None = None,
    retry: int | None = None,
) -> None:
    """Enqueue a new event for delivery.

    Args:
        data (Any): The payload (string, bytes or JSON-serializable).
        event (str | None): Optional event name.
        id (str | None): Optional Last-Event-ID.
        retry (int | None): Optional reconnect hint in milliseconds.
    """
    await self._queue.put(
        ServerSentEvent(data=data, event=event, id=id, retry=retry),
    )

publish_event async

publish_event(event: ServerSentEvent) -> None

Enqueue a pre-built :class:ServerSentEvent.

Parameters:

Name Type Description Default
event ServerSentEvent

The event to enqueue.

required
Source code in tempest_fastapi_sdk/sse/event_stream.py
async def publish_event(self, event: ServerSentEvent) -> None:
    """Enqueue a pre-built :class:`ServerSentEvent`.

    Args:
        event (ServerSentEvent): The event to enqueue.
    """
    await self._queue.put(event)

close async

close() -> None

Signal the stream to end after draining queued events.

Source code in tempest_fastapi_sdk/sse/event_stream.py
async def close(self) -> None:
    """Signal the stream to end after draining queued events."""
    await self._queue.put(None)

stream async

stream() -> AsyncIterator[bytes]

Yield encoded SSE bytes until :meth:close is invoked.

Yields:

Name Type Description
bytes AsyncIterator[bytes]

An encoded SSE frame ready to write to the wire.

Source code in tempest_fastapi_sdk/sse/event_stream.py
async def stream(self) -> AsyncIterator[bytes]:
    """Yield encoded SSE bytes until :meth:`close` is invoked.

    Yields:
        bytes: An encoded SSE frame ready to write to the wire.
    """
    while True:
        event: ServerSentEvent | None
        if self.heartbeat_seconds is not None:
            try:
                event = await asyncio.wait_for(
                    self._queue.get(),
                    timeout=self.heartbeat_seconds,
                )
            except TimeoutError:
                yield ServerSentEvent(comment="keepalive").encode().encode("utf-8")
                continue
        else:
            event = await self._queue.get()
        if event is None:
            return
        yield event.encode().encode("utf-8")

ServerSentEvent dataclass

ServerSentEvent(
    data: Any = "",
    event: str | None = None,
    id: str | None = None,
    retry: int | None = None,
    comment: str | None = None,
)

A single SSE frame.

Encodes to the line-based wire format defined by the spec (https://html.spec.whatwg.org/multipage/server-sent-events.html). data may be a string, bytes, or any JSON-serializable Python object — non-string payloads are JSON-encoded before transmission.

Attributes:

Name Type Description
data Any

The event payload.

event str | None

Optional event name; the browser routes EventSource.addEventListener(name, ...) by this.

id str | None

Optional Last-Event-ID value used by the browser to resume after a reconnect.

retry int | None

Reconnection delay (milliseconds) the browser should use after a connection drop.

comment str | None

Optional comment line prepended to the frame (renders as : comment); useful for heartbeats.

encode

encode() -> str

Render the event as the wire-format string.

Returns:

Name Type Description
str str

The encoded event, including the trailing blank line

str

that marks frame boundaries.

Source code in tempest_fastapi_sdk/sse/event_stream.py
def encode(self) -> str:
    """Render the event as the wire-format string.

    Returns:
        str: The encoded event, including the trailing blank line
        that marks frame boundaries.
    """
    lines: list[str] = []
    if self.comment is not None:
        lines.append(f": {self.comment}")
    if self.event is not None:
        lines.append(f"event: {self.event}")
    if self.id is not None:
        lines.append(f"id: {self.id}")
    if self.retry is not None:
        lines.append(f"retry: {self.retry}")

    payload: str
    if isinstance(self.data, bytes):
        payload = self.data.decode("utf-8")
    elif isinstance(self.data, str):
        payload = self.data
    else:
        payload = json.dumps(self.data, default=str)

    for chunk in payload.splitlines() or [""]:
        lines.append(f"data: {chunk}")
    return "\n".join(lines) + "\n\n"

sse_response

sse_response(
    stream: AsyncIterable[bytes],
    *,
    status_code: int = 200,
    headers: dict[str, str] | None = None,
) -> StreamingResponse

Wrap stream in a Starlette text/event-stream response.

Adds the SSE-specific headers (Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no) so intermediate proxies don't buffer or cache the long-lived response. Caller-supplied headers are layered below the SSE defaults so the three critical headers above cannot be accidentally overridden — pass extra metadata, not replacements.

Parameters:

Name Type Description Default
stream AsyncIterable[bytes]

The byte stream produced by :meth:EventStream.stream (or any compatible generator).

required
status_code int

HTTP status code. Defaults to 200.

200
headers dict[str, str] | None

Extra headers to attach.

None

Returns:

Name Type Description
StreamingResponse StreamingResponse

A ready-to-return SSE response.

Source code in tempest_fastapi_sdk/sse/event_stream.py
def sse_response(
    stream: AsyncIterable[bytes],
    *,
    status_code: int = 200,
    headers: dict[str, str] | None = None,
) -> StreamingResponse:
    """Wrap ``stream`` in a Starlette ``text/event-stream`` response.

    Adds the SSE-specific headers (``Cache-Control: no-cache``,
    ``Connection: keep-alive``, ``X-Accel-Buffering: no``) so
    intermediate proxies don't buffer or cache the long-lived
    response. Caller-supplied ``headers`` are layered **below** the
    SSE defaults so the three critical headers above cannot be
    accidentally overridden — pass extra metadata, not replacements.

    Args:
        stream (AsyncIterable[bytes]): The byte stream produced by
            :meth:`EventStream.stream` (or any compatible generator).
        status_code (int): HTTP status code. Defaults to ``200``.
        headers (dict[str, str] | None): Extra headers to attach.

    Returns:
        StreamingResponse: A ready-to-return SSE response.
    """
    merged: dict[str, str] = {**(headers or {}), **_SSE_HEADERS}
    return StreamingResponse(
        stream,
        media_type="text/event-stream",
        status_code=status_code,
        headers=merged,
    )

WebSocket

tempest_fastapi_sdk.websockets

WebSocketHub

WebSocketHub(*, max_per_user: int = 5)

In-process registry of live WebSocket connections.

Tracks every connection accepted by :func:tempest_fastapi_sdk.make_websocket_router and offers three delivery patterns:

  • send_to(user_id, envelope) — every socket the user has open right now.
  • broadcast(envelope, topic=...) — every subscriber of topic (or every connection when topic is omitted).
  • subscribe(connection_id, topic) / unsubscribe(connection_id, topic) — per-connection topic membership.

This hub is single-process. For multi-replica deployments, fan out across processes via a pub/sub backend (Redis pub/sub, RabbitMQ topic exchange) — the hub itself only handles in-process delivery. The future RedisWebSocketHub will swap the local broadcast for a redis-driven one without changing the public surface; today, run a single replica or use sticky sessions when WebSocket fan-out matters.

The hub is safe to share across handlers in the same FastAPI app — all mutators take an asyncio.Lock so concurrent register/unregister calls do not corrupt the internal state.

Initialize the hub.

Parameters:

Name Type Description Default
max_per_user int

Cap on concurrent connections per user. When the cap is hit on register, the oldest connection for that user is force-closed with code 4429 and removed before the new one is registered.

5
Source code in tempest_fastapi_sdk/websockets/hub.py
def __init__(self, *, max_per_user: int = 5) -> None:
    """Initialize the hub.

    Args:
        max_per_user (int): Cap on concurrent connections per
            user. When the cap is hit on ``register``, the
            oldest connection for that user is force-closed
            with code ``4429`` and removed before the new one
            is registered.
    """
    self._connections: dict[UUID, WebSocketConnection] = {}
    self._by_user: dict[UUID, list[UUID]] = {}
    self._by_topic: dict[str, set[UUID]] = {}
    self._lock: asyncio.Lock = asyncio.Lock()
    self.max_per_user: int = max_per_user

register async

register(user_id: UUID, ws: WebSocket) -> WebSocketConnection

Register an accepted WebSocket against user_id.

When the user is already at max_per_user open connections, the oldest one is force-closed (code 4429) before the new connection is admitted.

Parameters:

Name Type Description Default
user_id UUID

The authenticated user owning the new connection.

required
ws WebSocket

The accepted FastAPI WebSocket.

required

Returns:

Name Type Description
WebSocketConnection WebSocketConnection

The registered handle. Pass its

WebSocketConnection

connection_id to :meth:unregister,

WebSocketConnection

meth:subscribe or :meth:unsubscribe.

Source code in tempest_fastapi_sdk/websockets/hub.py
async def register(self, user_id: UUID, ws: WebSocket) -> WebSocketConnection:
    """Register an accepted WebSocket against ``user_id``.

    When the user is already at ``max_per_user`` open
    connections, the oldest one is force-closed (code ``4429``)
    before the new connection is admitted.

    Args:
        user_id (UUID): The authenticated user owning the new
            connection.
        ws (WebSocket): The accepted FastAPI WebSocket.

    Returns:
        WebSocketConnection: The registered handle. Pass its
        ``connection_id`` to :meth:`unregister`,
        :meth:`subscribe` or :meth:`unsubscribe`.
    """
    async with self._lock:
        existing = self._by_user.get(user_id, [])
        if len(existing) >= self.max_per_user:
            oldest_id = existing[0]
            await self._evict_locked(oldest_id, code=4429)
        connection = WebSocketConnection(
            connection_id=uuid4(),
            user_id=user_id,
            ws=ws,
        )
        self._connections[connection.connection_id] = connection
        self._by_user.setdefault(user_id, []).append(connection.connection_id)
        return connection

unregister async

unregister(connection_id: UUID) -> None

Remove a connection from every index. Idempotent.

Source code in tempest_fastapi_sdk/websockets/hub.py
async def unregister(self, connection_id: UUID) -> None:
    """Remove a connection from every index. Idempotent."""
    async with self._lock:
        await self._evict_locked(connection_id, code=None)

subscribe async

subscribe(connection_id: UUID, topic: str) -> None

Add topic to the connection's subscription set.

Source code in tempest_fastapi_sdk/websockets/hub.py
async def subscribe(self, connection_id: UUID, topic: str) -> None:
    """Add ``topic`` to the connection's subscription set."""
    async with self._lock:
        connection = self._connections.get(connection_id)
        if connection is None:
            return
        connection.topics.add(topic)
        self._by_topic.setdefault(topic, set()).add(connection_id)

unsubscribe async

unsubscribe(connection_id: UUID, topic: str) -> None

Drop topic from the connection's subscription set.

Source code in tempest_fastapi_sdk/websockets/hub.py
async def unsubscribe(self, connection_id: UUID, topic: str) -> None:
    """Drop ``topic`` from the connection's subscription set."""
    async with self._lock:
        connection = self._connections.get(connection_id)
        if connection is None:
            return
        connection.topics.discard(topic)
        self._by_topic.get(topic, set()).discard(connection_id)
        if not self._by_topic.get(topic):
            self._by_topic.pop(topic, None)

send_to async

send_to(user_id: UUID, envelope: WSEnvelope) -> int

Send envelope to every connection owned by user_id.

Parameters:

Name Type Description Default
user_id UUID

Target user.

required
envelope WSEnvelope

Frame to deliver.

required

Returns:

Name Type Description
int int

Number of sockets that successfully received the

int

frame. Dead connections are evicted transparently and do

int

not count.

Source code in tempest_fastapi_sdk/websockets/hub.py
async def send_to(self, user_id: UUID, envelope: WSEnvelope) -> int:
    """Send ``envelope`` to every connection owned by ``user_id``.

    Args:
        user_id (UUID): Target user.
        envelope (WSEnvelope): Frame to deliver.

    Returns:
        int: Number of sockets that successfully received the
        frame. Dead connections are evicted transparently and do
        not count.
    """
    async with self._lock:
        targets = list(self._by_user.get(user_id, []))
    return await self._fan_out(targets, envelope)

broadcast async

broadcast(envelope: WSEnvelope, *, topic: str | None = None) -> int

Deliver envelope to every subscriber of topic.

When topic is None, the envelope is delivered to every active connection across every user — useful for system-wide announcements, but expensive at scale; prefer topic-scoped delivery when you can.

Parameters:

Name Type Description Default
envelope WSEnvelope

Frame to deliver.

required
topic str | None

Topic to fan out on, or None for everyone.

None

Returns:

Name Type Description
int int

Number of successful sends. See :meth:send_to.

Source code in tempest_fastapi_sdk/websockets/hub.py
async def broadcast(
    self,
    envelope: WSEnvelope,
    *,
    topic: str | None = None,
) -> int:
    """Deliver ``envelope`` to every subscriber of ``topic``.

    When ``topic`` is ``None``, the envelope is delivered to
    every active connection across every user — useful for
    system-wide announcements, but expensive at scale; prefer
    topic-scoped delivery when you can.

    Args:
        envelope (WSEnvelope): Frame to deliver.
        topic (str | None): Topic to fan out on, or ``None`` for
            everyone.

    Returns:
        int: Number of successful sends. See :meth:`send_to`.
    """
    async with self._lock:
        if topic is None:
            targets = list(self._connections.keys())
        else:
            targets = list(self._by_topic.get(topic, set()))
    return await self._fan_out(targets, envelope)

online_users

online_users() -> set[UUID]

Return the set of users with at least one active connection.

Source code in tempest_fastapi_sdk/websockets/hub.py
def online_users(self) -> set[UUID]:
    """Return the set of users with at least one active connection."""
    return set(self._by_user.keys())

connection_count

connection_count() -> int

Return the total number of live connections.

Source code in tempest_fastapi_sdk/websockets/hub.py
def connection_count(self) -> int:
    """Return the total number of live connections."""
    return len(self._connections)

topic_count

topic_count(topic: str) -> int

Return the number of subscribers for topic.

Source code in tempest_fastapi_sdk/websockets/hub.py
def topic_count(self, topic: str) -> int:
    """Return the number of subscribers for ``topic``."""
    return len(self._by_topic.get(topic, set()))

WebSocketConnection dataclass

WebSocketConnection(
    connection_id: UUID, user_id: UUID, ws: WebSocket, topics: set[str] = set()
)

A single live WebSocket bound to an authenticated user.

Attributes:

Name Type Description
connection_id UUID

Unique identifier; the hub keys connections by this so the same user can hold several sockets at once (e.g. multi-tab).

user_id UUID

The user the connection belongs to. Set by WebSocketHub.register based on whatever the bearer resolver returns.

ws WebSocket

The underlying FastAPI/Starlette socket.

topics set[str]

Set of topic strings the connection has subscribed to. Populated by :meth:WebSocketHub.subscribe.

make_websocket_router

make_websocket_router(
    handler: WSHandler,
    *,
    hub: WebSocketHub,
    bearer_resolver: BearerResolver,
    settings: WebSocketSettings,
    path: str = "/ws",
    tags: list[str] | None = None,
) -> APIRouter

Build a single-endpoint WebSocket router.

Parameters:

Name Type Description Default
handler WSHandler

Coroutine the SDK invokes once per authenticated connection. The handler is responsible for the message loop; the router takes care of auth + heartbeat + hub registration.

required
hub WebSocketHub

Shared hub for broadcast / send_to. One hub instance per FastAPI app is the usual setup.

required
bearer_resolver BearerResolver

Awaitable returning the user UUID for a token, or None on bad / expired tokens.

required
settings WebSocketSettings

Heartbeat / cap / size limits.

required
path str

Mount path. Defaults to "/ws".

'/ws'
tags list[str] | None

OpenAPI tags. Defaults to ["websocket"].

None

Returns:

Name Type Description
APIRouter APIRouter

Ready to mount with app.include_router.

Source code in tempest_fastapi_sdk/websockets/router.py
def make_websocket_router(
    handler: WSHandler,
    *,
    hub: WebSocketHub,
    bearer_resolver: BearerResolver,
    settings: WebSocketSettings,
    path: str = "/ws",
    tags: list[str] | None = None,
) -> APIRouter:
    """Build a single-endpoint WebSocket router.

    Args:
        handler (WSHandler): Coroutine the SDK invokes once per
            authenticated connection. The handler is responsible
            for the message loop; the router takes care of auth +
            heartbeat + hub registration.
        hub (WebSocketHub): Shared hub for broadcast / send_to. One
            hub instance per FastAPI app is the usual setup.
        bearer_resolver (BearerResolver): Awaitable returning the
            user UUID for a token, or ``None`` on bad / expired
            tokens.
        settings (WebSocketSettings): Heartbeat / cap / size limits.
        path (str): Mount path. Defaults to ``"/ws"``.
        tags (list[str] | None): OpenAPI tags. Defaults to
            ``["websocket"]``.

    Returns:
        APIRouter: Ready to mount with ``app.include_router``.
    """
    router = APIRouter(tags=list(tags or ["websocket"]))

    @router.websocket(path)
    async def websocket_endpoint(
        ws: WebSocket,
        token: str | None = Query(default=None),
    ) -> None:
        bearer = _extract_bearer(ws, token)
        if bearer is None:
            await ws.close(code=4401)
            return
        user_id = await bearer_resolver(bearer)
        if user_id is None:
            await ws.close(code=4401)
            return
        await ws.accept(
            subprotocol=_negotiated_subprotocol(ws),
        )
        connection = await hub.register(user_id, ws)
        heartbeat = asyncio.create_task(
            _heartbeat_loop(ws, settings=settings),
        )
        try:
            await handler(ws, connection, hub)
        except WebSocketDisconnect:
            pass
        finally:
            heartbeat.cancel()
            await hub.unregister(connection.connection_id)
            if ws.application_state != WebSocketState.DISCONNECTED:
                with contextlib.suppress(Exception):
                    await ws.close(code=status.WS_1000_NORMAL_CLOSURE)

    return router

WSEnvelope

Bases: BaseSchema

Canonical message envelope for the bundled WebSocket router.

Every frame the SDK sends — application data, heartbeats, errors — fits this shape so clients can dispatch on type alone. Senders on the consumer side are encouraged to use it too, but the router accepts any JSON payload and only the heartbeat frames it owns are strictly required to follow this schema.

Attributes:

Name Type Description
type str

Event name. Reserved values: "ping" / "pong" (heartbeat).

data dict[str, Any]

Payload — empty dict when none.

request_id str | None

Echoes the originating HTTP request-id for end-to-end tracing across SSE/HTTP/WS.


Web Push

tempest_fastapi_sdk.webpush

WebPushDispatcher

WebPushDispatcher(
    vapid_private_key: str,
    *,
    vapid_subject: str,
    ttl_seconds: int = 60,
    extra_vapid_claims: dict[str, str] | None = None,
)

Send VAPID-signed Web Push notifications to browser subscribers.

Wraps the synchronous pywebpush library in :func:asyncio.to_thread so dispatch fits the SDK's async-first convention. Subscriptions that respond with 404/410 raise :class:WebPushGoneError so the caller can prune their store; every other failure raises :class:WebPushError.

Attributes:

Name Type Description
vapid_private_key str

VAPID private key (PEM or base64url encoded). MUST match the public key advertised to clients.

vapid_claims dict[str, str]

Mandatory JWT claims attached to every push. sub is required (typically mailto:ops@example.com).

ttl_seconds int

Default time-to-live applied to each push (the push service buffers the payload for at most this long when the device is offline).

Initialize the dispatcher.

Parameters:

Name Type Description Default
vapid_private_key str

VAPID private key.

required
vapid_subject str

The sub JWT claim. Browsers expect either a mailto: or https: URI.

required
ttl_seconds int

Default TTL for delivered messages.

60
extra_vapid_claims dict[str, str] | None

Additional claims merged into the JWT.

None
Source code in tempest_fastapi_sdk/webpush/dispatcher.py
def __init__(
    self,
    vapid_private_key: str,
    *,
    vapid_subject: str,
    ttl_seconds: int = 60,
    extra_vapid_claims: dict[str, str] | None = None,
) -> None:
    """Initialize the dispatcher.

    Args:
        vapid_private_key (str): VAPID private key.
        vapid_subject (str): The ``sub`` JWT claim. Browsers
            expect either a ``mailto:`` or ``https:`` URI.
        ttl_seconds (int): Default TTL for delivered messages.
        extra_vapid_claims (dict[str, str] | None): Additional
            claims merged into the JWT.
    """
    self.vapid_private_key: str = vapid_private_key
    self.vapid_claims: dict[str, str] = {"sub": vapid_subject}
    if extra_vapid_claims:
        self.vapid_claims.update(extra_vapid_claims)
    self.ttl_seconds: int = ttl_seconds

send async

send(
    subscription: WebPushSubscriptionSchema,
    payload: WebPushPayloadSchema | dict[str, Any] | str | bytes,
    *,
    ttl_seconds: int | None = None,
    headers: dict[str, str] | None = None,
) -> None

Send a single push notification.

Parameters:

Name Type Description Default
subscription WebPushSubscriptionSchema

The recipient.

required
payload WebPushPayloadSchema | dict | str | bytes

The notification body. Pydantic models and dicts are JSON-encoded; strings/bytes are sent as-is.

required
ttl_seconds int | None

Override :attr:ttl_seconds for this dispatch.

None
headers dict[str, str] | None

Extra HTTP headers to attach to the push request (forwarded to pywebpush).

None

Raises:

Type Description
WebPushGoneError

When the push service returns 404/410.

WebPushError

For any other delivery failure.

Source code in tempest_fastapi_sdk/webpush/dispatcher.py
async def send(
    self,
    subscription: WebPushSubscriptionSchema,
    payload: WebPushPayloadSchema | dict[str, Any] | str | bytes,
    *,
    ttl_seconds: int | None = None,
    headers: dict[str, str] | None = None,
) -> None:
    """Send a single push notification.

    Args:
        subscription (WebPushSubscriptionSchema): The recipient.
        payload (WebPushPayloadSchema | dict | str | bytes): The
            notification body. Pydantic models and dicts are
            JSON-encoded; strings/bytes are sent as-is.
        ttl_seconds (int | None): Override
            :attr:`ttl_seconds` for this dispatch.
        headers (dict[str, str] | None): Extra HTTP headers to
            attach to the push request (forwarded to pywebpush).

    Raises:
        WebPushGoneError: When the push service returns 404/410.
        WebPushError: For any other delivery failure.
    """
    pywebpush = _require_pywebpush()

    if isinstance(payload, WebPushPayloadSchema):
        data: str | bytes = payload.to_json()
    elif isinstance(payload, dict):
        data = json.dumps(payload, default=str)
    else:
        data = payload

    sub_info = {
        "endpoint": subscription.endpoint,
        "keys": {
            "p256dh": subscription.keys.p256dh,
            "auth": subscription.keys.auth,
        },
    }
    effective_ttl = ttl_seconds if ttl_seconds is not None else self.ttl_seconds

    def _dispatch() -> None:
        try:
            pywebpush.webpush(
                subscription_info=sub_info,
                data=data,
                vapid_private_key=self.vapid_private_key,
                vapid_claims=dict(self.vapid_claims),
                ttl=effective_ttl,
                headers=headers,
            )
        except pywebpush.WebPushException as exc:
            status = exc.response.status_code if exc.response is not None else None
            masked = _mask_endpoint(subscription.endpoint)
            if status in {404, 410}:
                raise WebPushGoneError(
                    f"Subscription gone (HTTP {status}) for {masked}",
                    status_code=status,
                    endpoint=subscription.endpoint,
                ) from exc
            raise WebPushError(
                f"Web Push delivery failed for {masked} (HTTP {status})",
                status_code=status,
                endpoint=subscription.endpoint,
            ) from exc

    await asyncio.to_thread(_dispatch)

send_many async

send_many(
    subscriptions: list[WebPushSubscriptionSchema],
    payload: WebPushPayloadSchema | dict[str, Any] | str | bytes,
    *,
    ttl_seconds: int | None = None,
    headers: dict[str, str] | None = None,
) -> list[str]

Fan out a single payload to many subscriptions.

Each dispatch runs concurrently via :func:asyncio.gather. Subscriptions that respond with 404/410 are returned so the caller can prune them; every other failure is logged and also returned in the gone list when the endpoint is known.

Parameters:

Name Type Description Default
subscriptions list[WebPushSubscriptionSchema]

Recipients.

required
payload WebPushPayloadSchema | dict[str, Any] | str | bytes

The notification body (same shapes as :meth:send).

required
ttl_seconds int | None

Override TTL.

None
headers dict[str, str] | None

Extra HTTP headers.

None

Returns:

Type Description
list[str]

list[str]: Endpoints whose subscription is gone and should

list[str]

be removed from the application's store.

Source code in tempest_fastapi_sdk/webpush/dispatcher.py
async def send_many(
    self,
    subscriptions: list[WebPushSubscriptionSchema],
    payload: WebPushPayloadSchema | dict[str, Any] | str | bytes,
    *,
    ttl_seconds: int | None = None,
    headers: dict[str, str] | None = None,
) -> list[str]:
    """Fan out a single payload to many subscriptions.

    Each dispatch runs concurrently via :func:`asyncio.gather`.
    Subscriptions that respond with 404/410 are returned so the
    caller can prune them; every other failure is logged and
    also returned in the gone list when the endpoint is known.

    Args:
        subscriptions (list[WebPushSubscriptionSchema]): Recipients.
        payload: The notification body (same shapes as :meth:`send`).
        ttl_seconds (int | None): Override TTL.
        headers (dict[str, str] | None): Extra HTTP headers.

    Returns:
        list[str]: Endpoints whose subscription is gone and should
        be removed from the application's store.
    """
    gone: list[str] = []

    async def _one(sub: WebPushSubscriptionSchema) -> None:
        try:
            await self.send(sub, payload, ttl_seconds=ttl_seconds, headers=headers)
        except WebPushGoneError:
            gone.append(sub.endpoint)
        except WebPushError as exc:
            logger.warning(
                "Web Push send failed for %s: %s",
                _mask_endpoint(sub.endpoint),
                exc,
            )

    await asyncio.gather(*(_one(sub) for sub in subscriptions))
    return gone

WebPushSubscriptionSchema

Bases: BaseSchema

Server-side representation of PushSubscription.toJSON().

Two browser-flavored field names (expirationTime) are exposed via aliases so the schema round-trips JSON produced by JSON.stringify(subscription) without manual key mangling.

Attributes:

Name Type Description
endpoint str

Push service endpoint URL.

keys WebPushKeysSchema

Encryption keys.

expiration_time int | None

Optional expiration timestamp in milliseconds since epoch. Aliased to expirationTime on the wire.

WebPushPayloadSchema

Bases: BaseSchema

Optional helper for the JSON payload delivered with each push.

Mirrors the Notification API options exposed in service workers; callers that want stricter typing can subclass this for their application-specific event types.

Attributes:

Name Type Description
title str | None

Notification title shown to the user.

body str | None

Notification body.

icon str | None

URL of the icon to display.

badge str | None

URL of the badge icon (Android).

tag str | None

Tag used to coalesce notifications.

data dict[str, Any] | None

Arbitrary application payload.

actions list[dict[str, Any]] | None

Action button specs.


Utils

tempest_fastapi_sdk.utils

PasswordUtils

PasswordUtils(*, rounds: int = 12)

Hash and verify passwords using bcrypt.

Stateless utility — instantiate once and reuse across the application. The cost factor (rounds) controls how slow hashing is; 12 is a sensible 2026 default. Raise it when CPU budget allows to keep up with hardware.

Attributes:

Name Type Description
rounds int

The bcrypt cost factor.

Initialize.

Parameters:

Name Type Description Default
rounds int

The bcrypt cost factor. Higher values make hashing slower and brute-force attacks harder. Defaults to 12.

12

Raises:

Type Description
ImportError

When the [auth] extra is not installed.

Source code in tempest_fastapi_sdk/utils/password.py
def __init__(self, *, rounds: int = 12) -> None:
    """Initialize.

    Args:
        rounds (int): The bcrypt cost factor. Higher values make
            hashing slower and brute-force attacks harder.
            Defaults to ``12``.

    Raises:
        ImportError: When the ``[auth]`` extra is not installed.
    """
    if _bcrypt is None:
        raise ImportError(
            "PasswordUtils requires the [auth] extra. "
            "Install with `pip install tempest-fastapi-sdk[auth]`."
        )
    self.rounds: int = rounds

hash

hash(plain: str) -> str

Hash a plaintext password.

Parameters:

Name Type Description Default
plain str

The plaintext password.

required

Returns:

Name Type Description
str str

The bcrypt hash encoded as a UTF-8 string, ready to

str

persist in a database column.

Source code in tempest_fastapi_sdk/utils/password.py
def hash(self, plain: str) -> str:
    """Hash a plaintext password.

    Args:
        plain (str): The plaintext password.

    Returns:
        str: The bcrypt hash encoded as a UTF-8 string, ready to
        persist in a database column.
    """
    salt = _bcrypt.gensalt(rounds=self.rounds)
    return _bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8")

verify

verify(plain: str, hashed: str) -> bool

Verify a plaintext password against an existing hash.

Catches malformed hashes and returns False rather than raising, so callers can branch on the boolean without bcrypt-specific error handling.

Parameters:

Name Type Description Default
plain str

The plaintext password to verify.

required
hashed str

The previously stored bcrypt hash.

required

Returns:

Name Type Description
bool bool

True if the password matches.

Source code in tempest_fastapi_sdk/utils/password.py
def verify(self, plain: str, hashed: str) -> bool:
    """Verify a plaintext password against an existing hash.

    Catches malformed hashes and returns ``False`` rather than
    raising, so callers can branch on the boolean without
    bcrypt-specific error handling.

    Args:
        plain (str): The plaintext password to verify.
        hashed (str): The previously stored bcrypt hash.

    Returns:
        bool: ``True`` if the password matches.
    """
    try:
        return bool(
            _bcrypt.checkpw(
                plain.encode("utf-8"),
                hashed.encode("utf-8"),
            )
        )
    except (ValueError, TypeError):
        return False

JWTUtils

JWTUtils(
    secret: str,
    *,
    algorithm: str = "HS256",
    default_ttl: timedelta = timedelta(hours=1),
    issuer: str | None = None,
)

Encode and decode JWTs using a shared secret.

Every token gets an iat (issued-at) and exp (expiry) claim populated automatically; the caller is responsible for the rest (sub, custom claims, etc.). When the helper is created with issuer=, the iss claim is also added on encode and verified on decode.

Attributes:

Name Type Description
algorithm str

The JWT signing algorithm.

default_ttl timedelta

Default expiration applied on :meth:encode when ttl is not provided.

Initialize.

Parameters:

Name Type Description Default
secret str

The signing key (HMAC) or private key (RSA/EC).

required
algorithm str

JWT algorithm. Defaults to "HS256". Use "RS256" / "ES256" for asymmetric setups.

'HS256'
default_ttl timedelta

TTL applied by :meth:encode when the caller doesn't pass one. Defaults to 1 hour.

timedelta(hours=1)
issuer str | None

Value for the iss claim. When set, :meth:decode rejects tokens whose iss doesn't match (i.e. domain-level isolation).

None

Raises:

Type Description
ImportError

When the [auth] extra is not installed.

Source code in tempest_fastapi_sdk/utils/jwt.py
def __init__(
    self,
    secret: str,
    *,
    algorithm: str = "HS256",
    default_ttl: timedelta = timedelta(hours=1),
    issuer: str | None = None,
) -> None:
    """Initialize.

    Args:
        secret (str): The signing key (HMAC) or private key (RSA/EC).
        algorithm (str): JWT algorithm. Defaults to ``"HS256"``.
            Use ``"RS256"`` / ``"ES256"`` for asymmetric setups.
        default_ttl (timedelta): TTL applied by :meth:`encode` when
            the caller doesn't pass one. Defaults to 1 hour.
        issuer (str | None): Value for the ``iss`` claim. When set,
            :meth:`decode` rejects tokens whose ``iss`` doesn't
            match (i.e. domain-level isolation).

    Raises:
        ImportError: When the ``[auth]`` extra is not installed.
    """
    if _jwt is None:
        raise ImportError(
            "JWTUtils requires the [auth] extra. "
            "Install with `pip install tempest-fastapi-sdk[auth]`."
        )
    self._secret: str = secret
    self.algorithm: str = algorithm
    self.default_ttl: timedelta = default_ttl
    self._issuer: str | None = issuer

encode

encode(payload: dict[str, Any], *, ttl: timedelta | None = None) -> str

Encode payload as a signed JWT.

Parameters:

Name Type Description Default
payload dict[str, Any]

Claims to include. Typically contains a stable subject ("sub": "<user-id>").

required
ttl timedelta | None

Override :attr:default_ttl for this call (e.g. shorter for password-reset tokens).

None

Returns:

Name Type Description
str str

The compact-serialized JWT.

Source code in tempest_fastapi_sdk/utils/jwt.py
def encode(
    self,
    payload: dict[str, Any],
    *,
    ttl: timedelta | None = None,
) -> str:
    """Encode ``payload`` as a signed JWT.

    Args:
        payload (dict[str, Any]): Claims to include. Typically
            contains a stable subject (``"sub": "<user-id>"``).
        ttl (timedelta | None): Override :attr:`default_ttl` for
            this call (e.g. shorter for password-reset tokens).

    Returns:
        str: The compact-serialized JWT.
    """
    now = datetime.now(UTC)
    claims: dict[str, Any] = {
        **payload,
        "iat": int(now.timestamp()),
        "exp": int((now + (ttl or self.default_ttl)).timestamp()),
    }
    if self._issuer is not None:
        claims.setdefault("iss", self._issuer)
    return _jwt.encode(claims, self._secret, algorithm=self.algorithm)

decode

decode(token: str) -> dict[str, Any]

Decode and verify a JWT.

Parameters:

Name Type Description Default
token str

The token to decode.

required

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The decoded claims.

Raises:

Type Description
ExpiredTokenException

When the exp claim is past.

InvalidTokenException

For every other validation failure (bad signature, wrong issuer, missing claim, malformed payload, etc.).

Source code in tempest_fastapi_sdk/utils/jwt.py
def decode(self, token: str) -> dict[str, Any]:
    """Decode and verify a JWT.

    Args:
        token (str): The token to decode.

    Returns:
        dict[str, Any]: The decoded claims.

    Raises:
        ExpiredTokenException: When the ``exp`` claim is past.
        InvalidTokenException: For every other validation
            failure (bad signature, wrong issuer, missing claim,
            malformed payload, etc.).
    """
    try:
        decoded: dict[str, Any] = _jwt.decode(
            token,
            self._secret,
            algorithms=[self.algorithm],
            issuer=self._issuer,
        )
        return decoded
    except _jwt.ExpiredSignatureError as exc:
        raise ExpiredTokenException() from exc
    except _jwt.InvalidTokenError as exc:
        raise InvalidTokenException() from exc

decode_or_none

decode_or_none(token: str) -> dict[str, Any] | None

Decode and verify a JWT, returning None on failure.

Convenience wrapper for opportunistic decoding (e.g. soft auth that downgrades the user to anonymous when the token is missing/bad).

Parameters:

Name Type Description Default
token str

The token to decode.

required

Returns:

Type Description
dict[str, Any] | None

dict[str, Any] | None: The decoded claims, or None

dict[str, Any] | None

when the token is invalid or expired.

Source code in tempest_fastapi_sdk/utils/jwt.py
def decode_or_none(self, token: str) -> dict[str, Any] | None:
    """Decode and verify a JWT, returning ``None`` on failure.

    Convenience wrapper for opportunistic decoding (e.g. soft
    auth that downgrades the user to anonymous when the token
    is missing/bad).

    Args:
        token (str): The token to decode.

    Returns:
        dict[str, Any] | None: The decoded claims, or ``None``
        when the token is invalid or expired.
    """
    try:
        return self.decode(token)
    except (InvalidTokenException, ExpiredTokenException):
        return None

EmailUtils

EmailUtils(
    host: str,
    port: int,
    *,
    from_addr: str,
    username: str | None = None,
    password: str | None = None,
    use_tls: bool = False,
    use_starttls: bool = True,
    timeout: float = 30.0,
    template_dir: str | Path | None = None,
)

Send transactional emails via SMTP.

Connection configuration is supplied at construction time; each :meth:send call opens a fresh SMTP connection (aiosmtplib's high-level send helper handles connect/login/quit). For high-volume scenarios consider holding a persistent connection via aiosmtplib.SMTP directly.

Attributes:

Name Type Description
host str

SMTP server hostname.

port int

SMTP port.

from_addr str

Default sender address used as the From header.

use_tls bool

Whether to connect using SSL/TLS from the start (port 465 style).

use_starttls bool

Whether to upgrade to TLS via STARTTLS after connect (port 587 style).

Initialize.

Parameters:

Name Type Description Default
host str

SMTP server hostname.

required
port int

SMTP port. Common values: 25 (plain), 465 (SSL/TLS), 587 (STARTTLS).

required
from_addr str

Default sender address.

required
username str | None

Auth username.

None
password str | None

Auth password.

None
use_tls bool

Connect using SSL/TLS immediately. Set this for port 465.

False
use_starttls bool

Upgrade to TLS via STARTTLS after connect. Set this for port 587 (default).

True
timeout float

SMTP socket timeout in seconds.

30.0
template_dir str | Path | None

Directory holding Jinja2 templates for :meth:render_template. Optional — templates can be opted into later, and the directory is only loaded on first render. Requires the [email] extra (Jinja2 ships alongside aiosmtplib).

None

Raises:

Type Description
ImportError

When the [email] extra is not installed.

Source code in tempest_fastapi_sdk/utils/email.py
def __init__(
    self,
    host: str,
    port: int,
    *,
    from_addr: str,
    username: str | None = None,
    password: str | None = None,
    use_tls: bool = False,
    use_starttls: bool = True,
    timeout: float = 30.0,
    template_dir: str | Path | None = None,
) -> None:
    """Initialize.

    Args:
        host (str): SMTP server hostname.
        port (int): SMTP port. Common values: ``25`` (plain),
            ``465`` (SSL/TLS), ``587`` (STARTTLS).
        from_addr (str): Default sender address.
        username (str | None): Auth username.
        password (str | None): Auth password.
        use_tls (bool): Connect using SSL/TLS immediately. Set
            this for port ``465``.
        use_starttls (bool): Upgrade to TLS via STARTTLS after
            connect. Set this for port ``587`` (default).
        timeout (float): SMTP socket timeout in seconds.
        template_dir (str | Path | None): Directory holding Jinja2
            templates for :meth:`render_template`. Optional —
            templates can be opted into later, and the directory is
            only loaded on first render. Requires the ``[email]``
            extra (Jinja2 ships alongside aiosmtplib).

    Raises:
        ImportError: When the ``[email]`` extra is not installed.
    """
    if _aiosmtplib is None:
        raise ImportError(
            "EmailUtils requires the [email] extra. "
            "Install with `pip install tempest-fastapi-sdk[email]`."
        )
    self.host: str = host
    self.port: int = port
    self.from_addr: str = from_addr
    self._username: str | None = username
    self._password: str | None = password
    self.use_tls: bool = use_tls
    self.use_starttls: bool = use_starttls
    self._timeout: float = timeout
    self._template_dir: Path | None = (
        Path(template_dir) if template_dir is not None else None
    )
    self._jinja_env: jinja2.Environment | None = None

send async

send(
    to: str | Iterable[str],
    subject: str,
    body: str,
    *,
    html: str | None = None,
    cc: Iterable[str] | None = None,
    bcc: Iterable[str] | None = None,
    attachments: Iterable[Path] | None = None,
    reply_to: str | None = None,
    from_addr: str | None = None,
) -> None

Send a single email.

Parameters:

Name Type Description Default
to str | Iterable[str]

Recipient address(es). Listed in the To header.

required
subject str

Subject line.

required
body str

Plain-text body. Always sent; the HTML alternative is added as a multipart child when html is also provided.

required
html str | None

Optional HTML alternative body.

None
cc Iterable[str] | None

Additional Cc recipients.

None
bcc Iterable[str] | None

Bcc recipients (added to the envelope, not the headers).

None
attachments Iterable[Path] | None

Files to attach.

None
reply_to str | None

Value for the Reply-To header.

None
from_addr str | None

Override the default sender for this message.

None

Raises:

Type Description
SMTPException

Re-raised on any SMTP error so callers can branch on the specific failure.

Source code in tempest_fastapi_sdk/utils/email.py
async def send(
    self,
    to: str | Iterable[str],
    subject: str,
    body: str,
    *,
    html: str | None = None,
    cc: Iterable[str] | None = None,
    bcc: Iterable[str] | None = None,
    attachments: Iterable[Path] | None = None,
    reply_to: str | None = None,
    from_addr: str | None = None,
) -> None:
    """Send a single email.

    Args:
        to (str | Iterable[str]): Recipient address(es). Listed
            in the ``To`` header.
        subject (str): Subject line.
        body (str): Plain-text body. Always sent; the HTML
            alternative is added as a multipart child when
            ``html`` is also provided.
        html (str | None): Optional HTML alternative body.
        cc (Iterable[str] | None): Additional ``Cc`` recipients.
        bcc (Iterable[str] | None): ``Bcc`` recipients (added to
            the envelope, not the headers).
        attachments (Iterable[Path] | None): Files to attach.
        reply_to (str | None): Value for the ``Reply-To`` header.
        from_addr (str | None): Override the default sender for
            this message.

    Raises:
        aiosmtplib.errors.SMTPException: Re-raised on any SMTP
            error so callers can branch on the specific failure.
    """
    recipients: list[str] = [to] if isinstance(to, str) else list(to)
    cc_list: list[str] = list(cc or [])
    bcc_list: list[str] = list(bcc or [])

    message = EmailMessage()
    message["From"] = from_addr or self.from_addr
    message["To"] = ", ".join(recipients)
    message["Subject"] = subject
    if cc_list:
        message["Cc"] = ", ".join(cc_list)
    if reply_to:
        message["Reply-To"] = reply_to
    message.set_content(body)
    if html is not None:
        message.add_alternative(html, subtype="html")

    if attachments:
        for path in attachments:
            data = path.read_bytes()
            message.add_attachment(
                data,
                maintype="application",
                subtype="octet-stream",
                filename=path.name,
            )

    assert _aiosmtplib is not None, "guarded by __init__"
    await _aiosmtplib.send(
        message,
        hostname=self.host,
        port=self.port,
        username=self._username,
        password=self._password,
        use_tls=self.use_tls,
        start_tls=self.use_starttls,
        timeout=self._timeout,
        recipients=recipients + cc_list + bcc_list,
    )

render_template

render_template(template_name: str, context: dict[str, Any]) -> str

Render a Jinja2 template from template_dir with context.

The Jinja environment is built lazily on first call and memoized — subsequent renders reuse the same loader. HTML autoescaping is enabled for .html / .htm / .xml templates so caller-supplied values cannot break out into markup.

Parameters:

Name Type Description Default
template_name str

Template filename relative to template_dir (e.g. "welcome.html", "password_reset.txt").

required
context dict[str, Any]

Variables exposed inside the template.

required

Returns:

Name Type Description
str str

Rendered template body — pass this directly to

str

meth:send as body (text) or html.

Raises:

Type Description
RuntimeError

When template_dir was not configured at construction time.

ImportError

When Jinja2 is missing (it ships with the [email] extra since v0.24.0; older installs may need to upgrade).

TemplateNotFound

When the file cannot be located under template_dir.

Example:

>>> emails = EmailUtils(..., template_dir="emails/")
>>> html = emails.render_template(
...     "welcome.html",
...     {"user_name": "Ana", "app_url": "https://app/"},
... )
>>> await emails.send(
...     "ana@example.com",
...     subject="Welcome!",
...     body="Welcome, Ana!",
...     html=html,
... )
Source code in tempest_fastapi_sdk/utils/email.py
def render_template(self, template_name: str, context: dict[str, Any]) -> str:
    """Render a Jinja2 template from ``template_dir`` with ``context``.

    The Jinja environment is built lazily on first call and
    memoized — subsequent renders reuse the same loader. HTML
    autoescaping is enabled for ``.html`` / ``.htm`` / ``.xml``
    templates so caller-supplied values cannot break out into
    markup.

    Args:
        template_name (str): Template filename relative to
            ``template_dir`` (e.g. ``"welcome.html"``,
            ``"password_reset.txt"``).
        context (dict[str, Any]): Variables exposed inside the
            template.

    Returns:
        str: Rendered template body — pass this directly to
        :meth:`send` as ``body`` (text) or ``html``.

    Raises:
        RuntimeError: When ``template_dir`` was not configured at
            construction time.
        ImportError: When Jinja2 is missing (it ships with the
            ``[email]`` extra since v0.24.0; older installs may
            need to upgrade).
        jinja2.TemplateNotFound: When the file cannot be located
            under ``template_dir``.

    Example:

        >>> emails = EmailUtils(..., template_dir="emails/")
        >>> html = emails.render_template(
        ...     "welcome.html",
        ...     {"user_name": "Ana", "app_url": "https://app/"},
        ... )
        >>> await emails.send(
        ...     "ana@example.com",
        ...     subject="Welcome!",
        ...     body="Welcome, Ana!",
        ...     html=html,
        ... )
    """
    if Environment is None:
        raise ImportError(
            "EmailUtils.render_template requires Jinja2. "
            "Install with `pip install tempest-fastapi-sdk[email]`."
        )
    if self._jinja_env is None:
        # ChoiceLoader: project templates first, SDK bundled
        # templates (auth/activation, auth/password_reset) as
        # fallback. Lets the bundled auth flow render its
        # default emails without forcing the caller to ship
        # ``template_dir``.
        from jinja2 import ChoiceLoader

        search_paths: list[Path] = []
        if self._template_dir is not None:
            search_paths.append(self._template_dir)
        sdk_auth_templates = Path(__file__).resolve().parent.parent / (
            "auth/templates"
        )
        if sdk_auth_templates.is_dir():
            search_paths.append(sdk_auth_templates)
        if not search_paths:
            raise RuntimeError(
                "EmailUtils.render_template needs either ``template_dir`` "
                "set or the SDK auth templates to be reachable."
            )
        self._jinja_env = Environment(
            loader=ChoiceLoader([FileSystemLoader(str(p)) for p in search_paths]),
            autoescape=select_autoescape(["html", "htm", "xml"]),
            enable_async=False,
        )
    template = self._jinja_env.get_template(template_name)
    rendered: str = template.render(**context)
    return rendered

UploadUtils

UploadUtils(
    upload_dir: Path | str,
    *,
    max_size_bytes: int | None = None,
    allowed_extensions: set[str] | None = None,
    allowed_mimetypes: set[str] | None = None,
    verify_magic_bytes: bool = False,
    chunk_size: int = 1024 * 1024,
)

Persist uploaded files to local disk with opt-in validation.

Validation is incremental: extension and MIME type are checked against the configured whitelists before reading any bytes; the file's real content is optionally sniffed from its first bytes (verify_magic_bytes); and size is enforced as the stream is consumed so oversized uploads don't fill the disk before being rejected.

Saved files are streamed in chunks so memory usage stays bounded regardless of the upload size.

Attributes:

Name Type Description
upload_dir Path

Base directory where files are persisted. Created on instantiation when missing.

max_size_bytes int | None

Reject uploads larger than this. None disables the size check.

allowed_extensions set[str] | None

Whitelist of file extensions (lowercase, no dot). None disables the extension check.

allowed_mimetypes set[str] | None

Whitelist of MIME types (lowercase). None disables the MIME check.

verify_magic_bytes bool

When True, the first bytes of every upload are sniffed (:func:sniff_mime) and the detected type must be consistent with the declared type / allow-list. Defends against polyglots and content/MIME mismatches. Only enable when every accepted format is one :func:sniff_mime recognizes (images, PDF) — otherwise a legitimate but unsniffable upload is rejected.

Initialize.

Parameters:

Name Type Description Default
upload_dir Path | str

Base directory. Created if missing (parents included).

required
max_size_bytes int | None

Reject uploads larger than this. None disables the size check.

None
allowed_extensions set[str] | None

Whitelist of file extensions. Leading dots and case are normalized internally so {"PNG", ".jpg"} works as expected.

None
allowed_mimetypes set[str] | None

Whitelist of MIME types (case-insensitive, e.g. {"image/png"}).

None
verify_magic_bytes bool

Sniff the first bytes of each upload and reject content that does not match its declared type / the allow-list. See the class attribute docs for the caveat. Default False.

False
chunk_size int

Stream read chunk in bytes. Defaults to 1 MiB; raise to trade memory for fewer syscalls.

1024 * 1024

Raises:

Type Description
ImportError

When the [upload] extra is not installed.

Source code in tempest_fastapi_sdk/utils/upload.py
def __init__(
    self,
    upload_dir: Path | str,
    *,
    max_size_bytes: int | None = None,
    allowed_extensions: set[str] | None = None,
    allowed_mimetypes: set[str] | None = None,
    verify_magic_bytes: bool = False,
    chunk_size: int = 1024 * 1024,
) -> None:
    """Initialize.

    Args:
        upload_dir (Path | str): Base directory. Created if
            missing (parents included).
        max_size_bytes (int | None): Reject uploads larger than
            this. ``None`` disables the size check.
        allowed_extensions (set[str] | None): Whitelist of file
            extensions. Leading dots and case are normalized
            internally so ``{"PNG", ".jpg"}`` works as expected.
        allowed_mimetypes (set[str] | None): Whitelist of MIME
            types (case-insensitive, e.g. ``{"image/png"}``).
        verify_magic_bytes (bool): Sniff the first bytes of each
            upload and reject content that does not match its
            declared type / the allow-list. See the class
            attribute docs for the caveat. Default ``False``.
        chunk_size (int): Stream read chunk in bytes. Defaults to
            1 MiB; raise to trade memory for fewer syscalls.

    Raises:
        ImportError: When the ``[upload]`` extra is not installed.
    """
    if _aiofiles is None:
        raise ImportError(
            "UploadUtils requires the [upload] extra. "
            "Install with `pip install tempest-fastapi-sdk[upload]`."
        )
    self.upload_dir: Path = Path(upload_dir)
    self.upload_dir.mkdir(parents=True, exist_ok=True)
    self.max_size_bytes: int | None = max_size_bytes
    self.allowed_extensions: set[str] | None = (
        {ext.lower().lstrip(".") for ext in allowed_extensions}
        if allowed_extensions is not None
        else None
    )
    self.allowed_mimetypes: set[str] | None = (
        {mime.lower() for mime in allowed_mimetypes}
        if allowed_mimetypes is not None
        else None
    )
    self.verify_magic_bytes: bool = verify_magic_bytes
    self._chunk_size: int = chunk_size

validate

validate(file: UploadFile) -> None

Validate extension and MIME type before reading the stream.

Size and content (magic bytes) cannot be checked here because they require reading the stream; they are enforced incrementally in :meth:save as bytes are consumed.

Parameters:

Name Type Description Default
file UploadFile

The FastAPI upload to validate.

required

Raises:

Type Description
InvalidFileTypeException

If the extension or MIME type is not in the configured whitelist.

Source code in tempest_fastapi_sdk/utils/upload.py
def validate(self, file: UploadFile) -> None:
    """Validate extension and MIME type before reading the stream.

    Size and content (magic bytes) cannot be checked here because
    they require reading the stream; they are enforced incrementally
    in :meth:`save` as bytes are consumed.

    Args:
        file (UploadFile): The FastAPI upload to validate.

    Raises:
        InvalidFileTypeException: If the extension or MIME type
            is not in the configured whitelist.
    """
    if self.allowed_extensions is not None:
        ext = Path(file.filename or "").suffix.lower().lstrip(".")
        if ext not in self.allowed_extensions:
            raise InvalidFileTypeException(
                details={
                    "extension": ext,
                    "allowed": sorted(self.allowed_extensions),
                },
            )
    if self.allowed_mimetypes is not None:
        mime = (file.content_type or "").lower()
        if mime not in self.allowed_mimetypes:
            raise InvalidFileTypeException(
                details={
                    "mimetype": mime,
                    "allowed": sorted(self.allowed_mimetypes),
                },
            )

save async

save(
    file: UploadFile,
    *,
    subdir: str = "",
    filename: str | None = None,
    keep_original_name: bool = False,
    content_validator: Callable[[bytes], bool] | None = None,
    storage: UploadStorage | None = None,
) -> Path

Persist file and return the final path.

By default writes to upload_dir on local disk. Pass storage=MinIOUploadStorage(client) to send the upload to MinIO/S3 — the validation pipeline (extension / MIME / size / magic bytes / content_validator) is identical for either backend.

Parameters:

Name Type Description Default
file UploadFile

The FastAPI upload.

required
subdir str

Optional sub-directory relative to upload_dir (e.g. "avatars"). Created on demand for local; used as a key prefix for remote.

''
filename str | None

Explicit final filename (e.g. f"{user_id}.jpg") for deterministic, addressable names. Reduced to its basename and guarded against path traversal. Takes precedence over keep_original_name.

None
keep_original_name bool

When True (and filename is not given), preserves the upload's original filename; otherwise generates a UUID-based name with the original extension. Default False so collisions are impossible.

False
content_validator Callable[[bytes], bool] | None

Optional predicate run on the first chunk read from the stream. Returning False aborts the save (and removes the partial file) before any further bytes are written — e.g. lambda b: sniff_mime(b) in {"image/png"}.

None
storage UploadStorage | None

Optional :class:UploadStorage backend (LocalUploadStorage / MinIOUploadStorage). When None (default), writes to upload_dir on local disk preserving the historical behavior. When set, the returned :class:Path is the storage key wrapped in Path for back-compat — call str(result) to get the bare key.

None

Returns:

Name Type Description
Path Path

Local absolute path when storage is None,

Path

or Path(storage_key) when a backend is used.

Raises:

Type Description
InvalidFileTypeException

If the extension/MIME violates the whitelist, the content_validator rejects the bytes, or verify_magic_bytes detects a content mismatch.

FileTooLargeException

If the stream exceeds max_size_bytes mid-write; the partial file is deleted before raising.

Source code in tempest_fastapi_sdk/utils/upload.py
async def save(
    self,
    file: UploadFile,
    *,
    subdir: str = "",
    filename: str | None = None,
    keep_original_name: bool = False,
    content_validator: Callable[[bytes], bool] | None = None,
    storage: UploadStorage | None = None,
) -> Path:
    """Persist ``file`` and return the final path.

    By default writes to ``upload_dir`` on local disk. Pass
    ``storage=MinIOUploadStorage(client)`` to send the upload to
    MinIO/S3 — the validation pipeline (extension / MIME / size /
    magic bytes / ``content_validator``) is identical for either
    backend.

    Args:
        file (UploadFile): The FastAPI upload.
        subdir (str): Optional sub-directory relative to
            ``upload_dir`` (e.g. ``"avatars"``). Created on
            demand for local; used as a key prefix for remote.
        filename (str | None): Explicit final filename (e.g.
            ``f"{user_id}.jpg"``) for deterministic, addressable
            names. Reduced to its basename and guarded against path
            traversal. Takes precedence over ``keep_original_name``.
        keep_original_name (bool): When ``True`` (and ``filename`` is
            not given), preserves the upload's original filename;
            otherwise generates a UUID-based name with the original
            extension. Default ``False`` so collisions are
            impossible.
        content_validator (Callable[[bytes], bool] | None): Optional
            predicate run on the first chunk read from the stream.
            Returning ``False`` aborts the save (and removes the
            partial file) before any further bytes are written —
            e.g. ``lambda b: sniff_mime(b) in {"image/png"}``.
        storage (UploadStorage | None): Optional :class:`UploadStorage` backend
            (``LocalUploadStorage`` / ``MinIOUploadStorage``).
            When ``None`` (default), writes to ``upload_dir`` on
            local disk preserving the historical behavior. When
            set, the returned :class:`Path` is the storage key
            wrapped in ``Path`` for back-compat — call
            ``str(result)`` to get the bare key.

    Returns:
        Path: Local absolute path when ``storage`` is ``None``,
        or ``Path(storage_key)`` when a backend is used.

    Raises:
        InvalidFileTypeException: If the extension/MIME violates the
            whitelist, the ``content_validator`` rejects the bytes,
            or ``verify_magic_bytes`` detects a content mismatch.
        FileTooLargeException: If the stream exceeds
            ``max_size_bytes`` mid-write; the partial file is
            deleted before raising.
    """
    self.validate(file)

    resolved_name = self._resolve_filename(
        file,
        filename=filename,
        keep_original_name=keep_original_name,
    )

    if storage is not None:
        return await self._save_via_storage(
            file,
            storage=storage,
            subdir=subdir,
            resolved_name=resolved_name,
            content_validator=content_validator,
        )

    base_dir = self.upload_dir.resolve()
    target_dir = (base_dir / subdir).resolve() if subdir else base_dir
    if base_dir != target_dir and base_dir not in target_dir.parents:
        raise InvalidFileTypeException(
            details={"subdir": subdir, "reason": "escapes upload_dir"},
        )
    target_dir.mkdir(parents=True, exist_ok=True)

    target_path = (target_dir / resolved_name).resolve()
    if base_dir != target_path.parent and base_dir not in target_path.parents:
        raise InvalidFileTypeException(
            details={
                "filename": resolved_name,
                "reason": "escapes upload_dir",
            },
        )

    assert _aiofiles is not None, "guarded by __init__"
    total = 0
    first_chunk = True
    try:
        async with _aiofiles.open(target_path, "wb") as out:
            while chunk := await file.read(self._chunk_size):
                if first_chunk:
                    first_chunk = False
                    self._verify_content(chunk, file, content_validator)
                total += len(chunk)
                if self.max_size_bytes is not None and total > self.max_size_bytes:
                    raise FileTooLargeException(
                        details={"max_size_bytes": self.max_size_bytes},
                    )
                await out.write(chunk)
    except Exception:
        target_path.unlink(missing_ok=True)
        raise

    return target_path.resolve()

delete

delete(path: Path | str) -> bool

Delete a previously saved file, bounded to upload_dir.

Rejects any path that resolves outside upload_dir — that way callers can forward a user-supplied filename without risking rm -rf semantics on the rest of the filesystem. Absolute paths are accepted only when they land under upload_dir; everything else is treated as relative to it.

Parameters:

Name Type Description Default
path Path | str

The file path to delete. Resolved against upload_dir when relative.

required

Returns:

Name Type Description
bool bool

True if the file existed and was deleted,

bool

False when it was already missing.

Raises:

Type Description
InvalidFileTypeException

When path resolves outside upload_dir (path traversal attempt).

Source code in tempest_fastapi_sdk/utils/upload.py
def delete(self, path: Path | str) -> bool:
    """Delete a previously saved file, bounded to ``upload_dir``.

    Rejects any path that resolves outside ``upload_dir`` — that
    way callers can forward a user-supplied filename without
    risking ``rm -rf`` semantics on the rest of the filesystem.
    Absolute paths are accepted only when they land under
    ``upload_dir``; everything else is treated as relative to it.

    Args:
        path (Path | str): The file path to delete. Resolved
            against ``upload_dir`` when relative.

    Returns:
        bool: ``True`` if the file existed and was deleted,
        ``False`` when it was already missing.

    Raises:
        InvalidFileTypeException: When ``path`` resolves outside
            ``upload_dir`` (path traversal attempt).
    """
    base_dir = self.upload_dir.resolve()
    raw = Path(path)
    candidate = raw if raw.is_absolute() else (base_dir / raw)
    target = candidate.resolve()
    if target != base_dir and base_dir not in target.parents:
        raise InvalidFileTypeException(
            details={"path": str(path), "reason": "escapes upload_dir"},
        )
    if not target.exists():
        return False
    target.unlink()
    return True

MetricsUtils

Aggregated CPU/RAM/disk/GPU readings for the current host.

Built on top of :mod:psutil (always required by the [metrics] extra) and pynvml (optional — NVIDIA GPU support degrades to an empty list when the library is missing or no NVIDIA device is present).

Every method has a synchronous and an asynchronous variant. Sync methods call :mod:psutil directly (most calls are non-blocking or block briefly for sampling); async variants run the same code via :func:asyncio.to_thread so they never stall the event loop when a longer sampling interval is requested.

Stateless — instantiation is unnecessary; every method is a classmethod.

cpu classmethod

cpu(*, interval: float = 0.1) -> CPUMetrics

Sample CPU usage.

Parameters:

Name Type Description Default
interval float

Sampling window in seconds. 0 returns the cumulative measure since the previous call (which is meaningless on first invocation). Defaults to a short blocking sample.

0.1

Returns:

Name Type Description
CPUMetrics CPUMetrics

The sampled metrics.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
def cpu(cls, *, interval: float = 0.1) -> CPUMetrics:
    """Sample CPU usage.

    Args:
        interval (float): Sampling window in seconds. ``0`` returns
            the cumulative measure since the previous call (which
            is meaningless on first invocation). Defaults to a
            short blocking sample.

    Returns:
        CPUMetrics: The sampled metrics.
    """
    psutil = _require_psutil()
    percent = float(psutil.cpu_percent(interval=interval))
    logical = psutil.cpu_count(logical=True) or 0
    physical = psutil.cpu_count(logical=False) or 0
    load: tuple[float, float, float] | None
    try:
        la = psutil.getloadavg()
        load = (float(la[0]), float(la[1]), float(la[2]))
    except (AttributeError, OSError):
        load = None
    return CPUMetrics(
        percent=percent,
        cores_logical=int(logical),
        cores_physical=int(physical),
        load_average=load,
    )

cpu_async async classmethod

cpu_async(*, interval: float = 0.1) -> CPUMetrics

Asyncio-friendly wrapper around :meth:cpu.

Parameters:

Name Type Description Default
interval float

Sampling window in seconds.

0.1

Returns:

Name Type Description
CPUMetrics CPUMetrics

The sampled metrics.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
async def cpu_async(cls, *, interval: float = 0.1) -> CPUMetrics:
    """Asyncio-friendly wrapper around :meth:`cpu`.

    Args:
        interval (float): Sampling window in seconds.

    Returns:
        CPUMetrics: The sampled metrics.
    """
    return await asyncio.to_thread(cls.cpu, interval=interval)

memory classmethod

memory() -> MemoryMetrics

Sample RAM usage.

Returns:

Name Type Description
MemoryMetrics MemoryMetrics

The current memory snapshot.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
def memory(cls) -> MemoryMetrics:
    """Sample RAM usage.

    Returns:
        MemoryMetrics: The current memory snapshot.
    """
    vm = _require_psutil().virtual_memory()
    return MemoryMetrics(
        total_bytes=int(vm.total),
        used_bytes=int(vm.used),
        available_bytes=int(vm.available),
        percent=float(vm.percent),
    )

memory_async async classmethod

memory_async() -> MemoryMetrics

Asyncio-friendly wrapper around :meth:memory.

Returns:

Name Type Description
MemoryMetrics MemoryMetrics

The current memory snapshot.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
async def memory_async(cls) -> MemoryMetrics:
    """Asyncio-friendly wrapper around :meth:`memory`.

    Returns:
        MemoryMetrics: The current memory snapshot.
    """
    return await asyncio.to_thread(cls.memory)

disk classmethod

disk(path: str = '/') -> DiskMetrics

Sample disk usage for path.

Parameters:

Name Type Description Default
path str

The filesystem path to inspect. Defaults to the root partition.

'/'

Returns:

Name Type Description
DiskMetrics DiskMetrics

The usage snapshot.

Raises:

Type Description
FileNotFoundError

When path does not exist.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
def disk(cls, path: str = "/") -> DiskMetrics:
    """Sample disk usage for ``path``.

    Args:
        path (str): The filesystem path to inspect. Defaults to
            the root partition.

    Returns:
        DiskMetrics: The usage snapshot.

    Raises:
        FileNotFoundError: When ``path`` does not exist.
    """
    usage = _require_psutil().disk_usage(path)
    return DiskMetrics(
        path=path,
        total_bytes=int(usage.total),
        used_bytes=int(usage.used),
        free_bytes=int(usage.free),
        percent=float(usage.percent),
    )

disks classmethod

disks(paths: list[str] | None = None) -> list[DiskMetrics]

Sample usage for multiple disks.

Parameters:

Name Type Description Default
paths list[str] | None

Paths to inspect. None defaults to ["/"].

None

Returns:

Type Description
list[DiskMetrics]

list[DiskMetrics]: One entry per resolvable path; paths

list[DiskMetrics]

that raise are logged and skipped.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
def disks(cls, paths: list[str] | None = None) -> list[DiskMetrics]:
    """Sample usage for multiple disks.

    Args:
        paths (list[str] | None): Paths to inspect. ``None``
            defaults to ``["/"]``.

    Returns:
        list[DiskMetrics]: One entry per resolvable path; paths
        that raise are logged and skipped.
    """
    targets = paths if paths is not None else ["/"]
    results: list[DiskMetrics] = []
    for path in targets:
        try:
            results.append(cls.disk(path))
        except (FileNotFoundError, PermissionError, OSError) as exc:
            logger.warning("Disk metrics for %r failed: %s", path, exc)
    return results

disks_async async classmethod

disks_async(paths: list[str] | None = None) -> list[DiskMetrics]

Asyncio-friendly wrapper around :meth:disks.

Parameters:

Name Type Description Default
paths list[str] | None

Paths to inspect.

None

Returns:

Type Description
list[DiskMetrics]

list[DiskMetrics]: The collected snapshots.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
async def disks_async(
    cls,
    paths: list[str] | None = None,
) -> list[DiskMetrics]:
    """Asyncio-friendly wrapper around :meth:`disks`.

    Args:
        paths (list[str] | None): Paths to inspect.

    Returns:
        list[DiskMetrics]: The collected snapshots.
    """
    return await asyncio.to_thread(cls.disks, paths)

gpus classmethod

gpus() -> list[GPUMetrics]

Sample NVIDIA GPU usage via pynvml when available.

Returns an empty list (without raising) when:

  • pynvml is not installed,
  • the NVML library cannot be loaded (no NVIDIA driver, WSL without compute, etc.), or
  • no NVIDIA devices are present.

Returns:

Type Description
list[GPUMetrics]

list[GPUMetrics]: One entry per detected GPU.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
def gpus(cls) -> list[GPUMetrics]:
    """Sample NVIDIA GPU usage via ``pynvml`` when available.

    Returns an empty list (without raising) when:

    * ``pynvml`` is not installed,
    * the NVML library cannot be loaded (no NVIDIA driver, WSL
      without compute, etc.), or
    * no NVIDIA devices are present.

    Returns:
        list[GPUMetrics]: One entry per detected GPU.
    """
    try:
        import pynvml
    except ImportError:
        return []

    try:
        pynvml.nvmlInit()
    except Exception as exc:
        logger.debug("NVML init failed: %s", exc)
        return []

    gpus: list[GPUMetrics] = []
    try:
        count = pynvml.nvmlDeviceGetCount()
        for index in range(count):
            handle = pynvml.nvmlDeviceGetHandleByIndex(index)
            name_raw = pynvml.nvmlDeviceGetName(handle)
            name = (
                name_raw.decode("utf-8")
                if isinstance(name_raw, bytes)
                else str(name_raw)
            )
            mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
            util = pynvml.nvmlDeviceGetUtilizationRates(handle)
            temperature: float | None
            try:
                temperature = float(
                    pynvml.nvmlDeviceGetTemperature(
                        handle, pynvml.NVML_TEMPERATURE_GPU
                    )
                )
            except Exception:
                temperature = None
            gpus.append(
                GPUMetrics(
                    index=index,
                    name=name,
                    memory_total_bytes=int(mem.total),
                    memory_used_bytes=int(mem.used),
                    memory_free_bytes=int(mem.free),
                    utilization_percent=float(util.gpu),
                    temperature_celsius=temperature,
                )
            )
    except Exception as exc:
        logger.warning("NVML enumeration failed: %s", exc)
    finally:
        with contextlib.suppress(Exception):
            pynvml.nvmlShutdown()
    return gpus

gpus_async async classmethod

gpus_async() -> list[GPUMetrics]

Asyncio-friendly wrapper around :meth:gpus.

Returns:

Type Description
list[GPUMetrics]

list[GPUMetrics]: One entry per detected GPU.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
async def gpus_async(cls) -> list[GPUMetrics]:
    """Asyncio-friendly wrapper around :meth:`gpus`.

    Returns:
        list[GPUMetrics]: One entry per detected GPU.
    """
    return await asyncio.to_thread(cls.gpus)

snapshot classmethod

snapshot(
    *, disk_paths: list[str] | None = None, cpu_interval: float = 0.1
) -> SystemMetrics

Build a full :class:SystemMetrics snapshot.

Parameters:

Name Type Description Default
disk_paths list[str] | None

Disks to inspect.

None
cpu_interval float

CPU sampling window.

0.1

Returns:

Name Type Description
SystemMetrics SystemMetrics

The combined snapshot.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
def snapshot(
    cls,
    *,
    disk_paths: list[str] | None = None,
    cpu_interval: float = 0.1,
) -> SystemMetrics:
    """Build a full :class:`SystemMetrics` snapshot.

    Args:
        disk_paths (list[str] | None): Disks to inspect.
        cpu_interval (float): CPU sampling window.

    Returns:
        SystemMetrics: The combined snapshot.
    """
    return SystemMetrics(
        cpu=cls.cpu(interval=cpu_interval),
        memory=cls.memory(),
        disks=cls.disks(disk_paths),
        gpus=cls.gpus(),
    )

snapshot_async async classmethod

snapshot_async(
    *, disk_paths: list[str] | None = None, cpu_interval: float = 0.1
) -> SystemMetrics

Asyncio-friendly wrapper around :meth:snapshot.

Runs every sub-collector concurrently via :func:asyncio.gather so the wall-clock cost is bounded by the slowest sample (typically CPU).

Parameters:

Name Type Description Default
disk_paths list[str] | None

Disks to inspect.

None
cpu_interval float

CPU sampling window.

0.1

Returns:

Name Type Description
SystemMetrics SystemMetrics

The combined snapshot.

Source code in tempest_fastapi_sdk/utils/metrics.py
@classmethod
async def snapshot_async(
    cls,
    *,
    disk_paths: list[str] | None = None,
    cpu_interval: float = 0.1,
) -> SystemMetrics:
    """Asyncio-friendly wrapper around :meth:`snapshot`.

    Runs every sub-collector concurrently via :func:`asyncio.gather`
    so the wall-clock cost is bounded by the slowest sample
    (typically CPU).

    Args:
        disk_paths (list[str] | None): Disks to inspect.
        cpu_interval (float): CPU sampling window.

    Returns:
        SystemMetrics: The combined snapshot.
    """
    cpu, memory, disks, gpus = await asyncio.gather(
        cls.cpu_async(interval=cpu_interval),
        cls.memory_async(),
        cls.disks_async(disk_paths),
        cls.gpus_async(),
    )
    return SystemMetrics(cpu=cpu, memory=memory, disks=disks, gpus=gpus)

LogUtils

LogUtils(
    name: str,
    *,
    level: str | int = "INFO",
    json_output: bool = True,
    log_dir: str | Path | None = "logs",
    stdout: bool = True,
    file_output: bool = True,
)

High-level logging facade used across SDK consumers.

Wraps :func:tempest_fastapi_sdk.configure_logging so callers can obtain a fully configured JSON logger with one line, and exposes structured info/warning/error/debug/exception methods that forward **fields as top-level keys on the JSON payload via Python's logging.LogRecord.extra.

The class can be used in two flavors:

  • Instance API — keeps a configured logger as state and exposes level methods directly. Recommended for service-wide singletons.
  • Static helpers — :meth:configure and :meth:get_logger for ad-hoc configuration without tying state to an object.

Attributes:

Name Type Description
logger Logger

The configured stdlib logger.

name str

The logger name.

Configure and bind a logger to this instance.

Mirrors :func:configure_logging defaults — stdout and file output are enabled out of the box, writing under logs/.

Parameters:

Name Type Description Default
name str

Logger name. Typically __name__ of the root module, or the service name.

required
level str | int

Minimum log level to emit. Accepts stdlib names ("INFO", "DEBUG") or integers.

'INFO'
json_output bool

When True (default), structured JSON output via :class:JSONFormatter. When False, a human-readable text formatter.

True
log_dir str | Path | None

Directory for per-level files. Defaults to "logs". Pass None to disable file logging.

'logs'
stdout bool

Attach the stdout handler. Defaults to True.

True
file_output bool

Attach the per-level + 500.log file handlers under log_dir. Defaults to True.

True
Source code in tempest_fastapi_sdk/utils/log.py
def __init__(
    self,
    name: str,
    *,
    level: str | int = "INFO",
    json_output: bool = True,
    log_dir: str | Path | None = "logs",
    stdout: bool = True,
    file_output: bool = True,
) -> None:
    """Configure and bind a logger to this instance.

    Mirrors :func:`configure_logging` defaults — stdout *and* file
    output are enabled out of the box, writing under ``logs/``.

    Args:
        name (str): Logger name. Typically ``__name__`` of the
            root module, or the service name.
        level (str | int): Minimum log level to emit. Accepts
            stdlib names (``"INFO"``, ``"DEBUG"``) or integers.
        json_output (bool): When ``True`` (default), structured
            JSON output via :class:`JSONFormatter`. When ``False``,
            a human-readable text formatter.
        log_dir (str | Path | None): Directory for per-level files.
            Defaults to ``"logs"``. Pass ``None`` to disable file
            logging.
        stdout (bool): Attach the stdout handler. Defaults to
            ``True``.
        file_output (bool): Attach the per-level + ``500.log`` file
            handlers under ``log_dir``. Defaults to ``True``.
    """
    self.name: str = name
    self.logger: logging.Logger = configure_logging(
        level=level,
        json_output=json_output,
        logger_name=name,
        log_dir=log_dir,
        stdout=stdout,
        file_output=file_output,
    )

configure staticmethod

configure(
    level: str | int = "INFO",
    *,
    json_output: bool = True,
    logger_name: str | None = None,
    log_dir: str | Path | None = "logs",
    stdout: bool = True,
    file_output: bool = True,
) -> Logger

Imperative shortcut for :func:configure_logging.

Forwards every keyword to :func:configure_logging so the two share defaults — stdout and file output enabled, logs/ directory used unless overridden.

Parameters:

Name Type Description Default
level str | int

Minimum log level.

'INFO'
json_output bool

Emit JSON when True.

True
logger_name str | None

Target logger; None configures the root logger.

None
log_dir str | Path | None

Directory for per-level files. Defaults to "logs". Pass None to disable file logging.

'logs'
stdout bool

Attach the stdout handler. Defaults to True.

True
file_output bool

Attach the per-level + 500.log file handlers under log_dir. Defaults to True.

True

Returns:

Type Description
Logger

logging.Logger: The configured logger.

Source code in tempest_fastapi_sdk/utils/log.py
@staticmethod
def configure(
    level: str | int = "INFO",
    *,
    json_output: bool = True,
    logger_name: str | None = None,
    log_dir: str | Path | None = "logs",
    stdout: bool = True,
    file_output: bool = True,
) -> logging.Logger:
    """Imperative shortcut for :func:`configure_logging`.

    Forwards every keyword to :func:`configure_logging` so the two
    share defaults — stdout *and* file output enabled, ``logs/``
    directory used unless overridden.

    Args:
        level (str | int): Minimum log level.
        json_output (bool): Emit JSON when ``True``.
        logger_name (str | None): Target logger; ``None`` configures
            the root logger.
        log_dir (str | Path | None): Directory for per-level files.
            Defaults to ``"logs"``. Pass ``None`` to disable file
            logging.
        stdout (bool): Attach the stdout handler. Defaults to
            ``True``.
        file_output (bool): Attach the per-level + ``500.log`` file
            handlers under ``log_dir``. Defaults to ``True``.

    Returns:
        logging.Logger: The configured logger.
    """
    return configure_logging(
        level=level,
        json_output=json_output,
        logger_name=logger_name,
        log_dir=log_dir,
        stdout=stdout,
        file_output=file_output,
    )

get_logger staticmethod

get_logger(name: str) -> Logger

Return the stdlib logger named name without reconfiguring.

Parameters:

Name Type Description Default
name str

The logger name.

required

Returns:

Type Description
Logger

logging.Logger: The (possibly unconfigured) logger.

Source code in tempest_fastapi_sdk/utils/log.py
@staticmethod
def get_logger(name: str) -> logging.Logger:
    """Return the stdlib logger named ``name`` without reconfiguring.

    Args:
        name (str): The logger name.

    Returns:
        logging.Logger: The (possibly unconfigured) logger.
    """
    return logging.getLogger(name)

current_request_id staticmethod

current_request_id() -> str | None

Return the current request ID from the contextvar.

Useful when callers want to surface the correlation ID outside the log line (e.g. in an HTTP response body).

Returns:

Type Description
str | None

str | None: The active request ID, or None.

Source code in tempest_fastapi_sdk/utils/log.py
@staticmethod
def current_request_id() -> str | None:
    """Return the current request ID from the contextvar.

    Useful when callers want to surface the correlation ID outside
    the log line (e.g. in an HTTP response body).

    Returns:
        str | None: The active request ID, or ``None``.
    """
    return get_request_id()

info

info(message: str, **fields: Any) -> None

Emit an INFO record.

Parameters:

Name Type Description Default
message str

The log message.

required
**fields Any

Extra structured fields merged into the JSON payload.

{}
Source code in tempest_fastapi_sdk/utils/log.py
def info(self, message: str, **fields: Any) -> None:
    """Emit an INFO record.

    Args:
        message (str): The log message.
        **fields (Any): Extra structured fields merged into the
            JSON payload.
    """
    self.logger.info(message, extra=fields)

debug

debug(message: str, **fields: Any) -> None

Emit a DEBUG record.

Parameters:

Name Type Description Default
message str

The log message.

required
**fields Any

Extra structured fields.

{}
Source code in tempest_fastapi_sdk/utils/log.py
def debug(self, message: str, **fields: Any) -> None:
    """Emit a DEBUG record.

    Args:
        message (str): The log message.
        **fields (Any): Extra structured fields.
    """
    self.logger.debug(message, extra=fields)

warning

warning(message: str, **fields: Any) -> None

Emit a WARNING record.

Parameters:

Name Type Description Default
message str

The log message.

required
**fields Any

Extra structured fields.

{}
Source code in tempest_fastapi_sdk/utils/log.py
def warning(self, message: str, **fields: Any) -> None:
    """Emit a WARNING record.

    Args:
        message (str): The log message.
        **fields (Any): Extra structured fields.
    """
    self.logger.warning(message, extra=fields)

error

error(message: str, **fields: Any) -> None

Emit an ERROR record.

Parameters:

Name Type Description Default
message str

The log message.

required
**fields Any

Extra structured fields.

{}
Source code in tempest_fastapi_sdk/utils/log.py
def error(self, message: str, **fields: Any) -> None:
    """Emit an ERROR record.

    Args:
        message (str): The log message.
        **fields (Any): Extra structured fields.
    """
    self.logger.error(message, extra=fields)

critical

critical(message: str, **fields: Any) -> None

Emit a CRITICAL record.

Parameters:

Name Type Description Default
message str

The log message.

required
**fields Any

Extra structured fields.

{}
Source code in tempest_fastapi_sdk/utils/log.py
def critical(self, message: str, **fields: Any) -> None:
    """Emit a CRITICAL record.

    Args:
        message (str): The log message.
        **fields (Any): Extra structured fields.
    """
    self.logger.critical(message, extra=fields)

exception

exception(message: str, **fields: Any) -> None

Emit an ERROR record with the current exception traceback.

Must be called from inside an except block — relies on logger.exception which inspects sys.exc_info().

Parameters:

Name Type Description Default
message str

The log message.

required
**fields Any

Extra structured fields.

{}
Source code in tempest_fastapi_sdk/utils/log.py
def exception(self, message: str, **fields: Any) -> None:
    """Emit an ERROR record with the current exception traceback.

    Must be called from inside an ``except`` block — relies on
    ``logger.exception`` which inspects ``sys.exc_info()``.

    Args:
        message (str): The log message.
        **fields (Any): Extra structured fields.
    """
    self.logger.exception(message, extra=fields)

AttemptThrottle

AttemptThrottle(
    backend: ThrottleBackend,
    *,
    max_attempts: int,
    window_seconds: int,
    namespace: str = "throttle",
    fail_open: bool = True,
)

Fixed-window failure counter over an injected async KV backend.

Initialize the throttle.

Parameters:

Name Type Description Default
backend ThrottleBackend

Async KV store (e.g. redis.asyncio.Redis).

required
max_attempts int

Failures allowed before a key is blocked. Must be >= 1.

required
window_seconds int

Sliding window length (also the TTL applied on the first failure). Must be > 0.

required
namespace str

Key prefix so multiple throttles can share a backend without colliding.

'throttle'
fail_open bool

When True (default), backend errors degrade to "allowed" instead of raising — a cache outage must not lock every user out.

True

Raises:

Type Description
ValueError

If max_attempts < 1 or window_seconds <= 0.

Source code in tempest_fastapi_sdk/utils/throttle.py
def __init__(
    self,
    backend: ThrottleBackend,
    *,
    max_attempts: int,
    window_seconds: int,
    namespace: str = "throttle",
    fail_open: bool = True,
) -> None:
    """Initialize the throttle.

    Args:
        backend (ThrottleBackend): Async KV store (e.g.
            ``redis.asyncio.Redis``).
        max_attempts (int): Failures allowed before a key is
            blocked. Must be ``>= 1``.
        window_seconds (int): Sliding window length (also the TTL
            applied on the first failure). Must be ``> 0``.
        namespace (str): Key prefix so multiple throttles can share
            a backend without colliding.
        fail_open (bool): When ``True`` (default), backend errors
            degrade to "allowed" instead of raising — a cache
            outage must not lock every user out.

    Raises:
        ValueError: If ``max_attempts < 1`` or ``window_seconds <= 0``.
    """
    if max_attempts < 1:
        raise ValueError("max_attempts must be >= 1")
    if window_seconds <= 0:
        raise ValueError("window_seconds must be > 0")
    self._backend: ThrottleBackend = backend
    self.max_attempts: int = max_attempts
    self.window_seconds: int = window_seconds
    self._namespace: str = namespace
    self._fail_open: bool = fail_open

status async

status(key: str) -> ThrottleStatus

Read the current status for key without mutating it.

Parameters:

Name Type Description Default
key str

The domain key (e.g. f"{event_id}:{ip}").

required

Returns:

Name Type Description
ThrottleStatus ThrottleStatus

Current attempts / blocked state. On a backend error with fail_open set, an empty, unblocked status.

Source code in tempest_fastapi_sdk/utils/throttle.py
async def status(self, key: str) -> ThrottleStatus:
    """Read the current status for ``key`` without mutating it.

    Args:
        key (str): The domain key (e.g. ``f"{event_id}:{ip}"``).

    Returns:
        ThrottleStatus: Current attempts / blocked state. On a
            backend error with ``fail_open`` set, an empty,
            unblocked status.
    """
    try:
        raw = await self._backend.get(self._key(key))
        attempts = int(raw) if raw is not None else 0
        ttl = await self._backend.ttl(self._key(key)) if attempts else 0
    except Exception:
        if self._fail_open:
            return ThrottleStatus(0, False, 0)
        raise
    return self._status(attempts, ttl)

hit async

hit(key: str) -> ThrottleStatus

Record one failure for key and return the new status.

Increments the counter and, on the first failure of a window, applies the TTL so the window expires on its own.

Parameters:

Name Type Description Default
key str

The domain key.

required

Returns:

Name Type Description
ThrottleStatus ThrottleStatus

Status after the increment. On a backend error with fail_open set, an empty, unblocked status.

Source code in tempest_fastapi_sdk/utils/throttle.py
async def hit(self, key: str) -> ThrottleStatus:
    """Record one failure for ``key`` and return the new status.

    Increments the counter and, on the first failure of a window,
    applies the TTL so the window expires on its own.

    Args:
        key (str): The domain key.

    Returns:
        ThrottleStatus: Status after the increment. On a backend
            error with ``fail_open`` set, an empty, unblocked status.
    """
    try:
        attempts = await self._backend.incr(self._key(key))
        if attempts == 1:
            await self._backend.expire(self._key(key), self.window_seconds)
        ttl = await self._backend.ttl(self._key(key))
    except Exception:
        if self._fail_open:
            return ThrottleStatus(0, False, 0)
        raise
    return self._status(attempts, ttl)

reset async

reset(key: str) -> None

Clear the counter for key (e.g. after a success).

Parameters:

Name Type Description Default
key str

The domain key.

required
Source code in tempest_fastapi_sdk/utils/throttle.py
async def reset(self, key: str) -> None:
    """Clear the counter for ``key`` (e.g. after a success).

    Args:
        key (str): The domain key.
    """
    try:
        await self._backend.delete(self._key(key))
    except Exception:
        if not self._fail_open:
            raise

raise_if_blocked async

raise_if_blocked(key: str, *, message: str | None = None) -> ThrottleStatus

Raise :class:TooManyRequestsException when key is blocked.

Parameters:

Name Type Description Default
key str

The domain key.

required
message str | None

Optional override for the 429 message.

None

Returns:

Name Type Description
ThrottleStatus ThrottleStatus

The (unblocked) status when within budget.

Raises:

Type Description
TooManyRequestsException

When the attempt budget for key is exhausted; carries Retry-After.

Source code in tempest_fastapi_sdk/utils/throttle.py
async def raise_if_blocked(
    self,
    key: str,
    *,
    message: str | None = None,
) -> ThrottleStatus:
    """Raise :class:`TooManyRequestsException` when ``key`` is blocked.

    Args:
        key (str): The domain key.
        message (str | None): Optional override for the 429 message.

    Returns:
        ThrottleStatus: The (unblocked) status when within budget.

    Raises:
        TooManyRequestsException: When the attempt budget for ``key``
            is exhausted; carries ``Retry-After``.
    """
    current = await self.status(key)
    if current.blocked:
        raise TooManyRequestsException(
            message=message,
            retry_after_seconds=current.retry_after_seconds,
        )
    return current