Skip to content

API reference

Auto-generated from the source docstrings and type hints.

Core

The Link model with deferred-href resolution.

A link's href may be a plain URL or a callable taking the active :class:~gazebo.context.RequestContext and returning a URL. Callable hrefs are resolved during JSON serialization, so links can be constructed in business logic with no request in hand. Resolution pulls the context from :data:~gazebo.context.link_context (set by the framework glue), falling back to a pydantic serialization context.

UrlResolver

UrlResolver = Callable[[RequestContext], object]

A callable that, given the request context, returns a URL (str or AnyUrl).

Url

Url = Annotated[
    Any,
    AfterValidator(_validate_href),
    PlainSerializer(
        _serialize_href, return_type=str, when_used='json'
    ),
    WithJsonSchema({'type': 'string', 'format': 'uri'}),
]

A URL field that accepts either a resolver callable or a concrete URL value.

Bases: OmitNullModel

An OGC-style link. Null fields are omitted on JSON serialization.

Source code in src/gazebo/link.py
class Link(OmitNullModel):
    """An OGC-style link. Null fields are omitted on JSON serialization."""

    model_config = ConfigDict(extra='allow')

    href: Url
    rel: str
    type: str | None = None
    title: str | None = None
    method: str | None = None
    headers: dict[str, str | list[str]] | None = None
    body: Any = None
    templated: bool | None = None

    # --- factories (framework-agnostic; resolve via RequestContext) -------

    @classmethod
    def self_link(
        cls,
        href: Url | None = None,
        *,
        rel: str = Rel.SELF,
        type: str | None = MediaType.JSON,
        **kwargs: Any,
    ) -> Self:
        """Link to the current request URL (absolute, proxy-correct)."""
        return cls.model_validate(
            {
                'href': href if href is not None else (lambda ctx: ctx.url),
                'rel': rel,
                'type': type,
                **kwargs,
            },
        )

    @classmethod
    def root_link(
        cls,
        *,
        landing: str = 'landing',
        rel: str = Rel.ROOT,
        type: str | None = MediaType.JSON,
        **kwargs: Any,
    ) -> Self:
        """Link to the API root/landing page, resolved by route name."""
        return cls.model_validate(
            {
                'href': lambda ctx: ctx.url_for(landing),
                'rel': rel,
                'type': type,
                **kwargs,
            },
        )

    @classmethod
    def to_route(
        cls,
        name: str,
        *,
        rel: str,
        type: str | None = MediaType.JSON,
        path: dict[str, Any] | None = None,
        template: Sequence[str] | None = None,
        query_template: Sequence[str] | None = None,
        **kwargs: Any,
    ) -> Self:
        """Link to a named route (resolved via ``ctx.url_for(name, **path)``).

        With ``template`` and/or ``query_template`` the link instead advertises an
        unbound RFC 6570 URI template (``templated: true``) for the client to expand:
        the named vars are left as ``{var}`` expressions rather than resolved.

        Args:
            name: The route name to resolve.
            rel: The link relation.
            type: The target media type.
            path: Path parameters for the route. Bound into the deferred
                resolver; not stored on the link itself.
            template: Path-position route variables to leave unbound as RFC 6570
                ``{var}`` expressions (resolved via
                :meth:`~gazebo.context.RequestContext.url_for_template`).
            query_template: Query variables to append as an RFC 6570 form-query
                expression — ``{?a,b}`` on a query-less base, or ``{&a,b}`` when the
                resolved base already carries a query string.
            **kwargs: Extra link fields (e.g. ``title``).
        """
        path = path or {}
        templated = bool(template) or bool(query_template)

        def resolve(ctx: RequestContext) -> str:
            if template:
                base = ctx.url_for_template(name, path, template)
            else:
                base = ctx.url_for(name, **path)
            if query_template:
                op = '&' if '?' in base else '?'
                base = f'{base}{{{op}{",".join(query_template)}}}'
            return base

        return cls.model_validate(
            {
                'href': resolve,
                'rel': rel,
                'type': type,
                'templated': True if templated else None,
                **kwargs,
            },
        )
self_link(
    href: Url | None = None,
    *,
    rel: str = Rel.SELF,
    type: str | None = MediaType.JSON,
    **kwargs: Any,
) -> Self

Link to the current request URL (absolute, proxy-correct).

Source code in src/gazebo/link.py
@classmethod
def self_link(
    cls,
    href: Url | None = None,
    *,
    rel: str = Rel.SELF,
    type: str | None = MediaType.JSON,
    **kwargs: Any,
) -> Self:
    """Link to the current request URL (absolute, proxy-correct)."""
    return cls.model_validate(
        {
            'href': href if href is not None else (lambda ctx: ctx.url),
            'rel': rel,
            'type': type,
            **kwargs,
        },
    )
root_link(
    *,
    landing: str = 'landing',
    rel: str = Rel.ROOT,
    type: str | None = MediaType.JSON,
    **kwargs: Any,
) -> Self

Link to the API root/landing page, resolved by route name.

Source code in src/gazebo/link.py
@classmethod
def root_link(
    cls,
    *,
    landing: str = 'landing',
    rel: str = Rel.ROOT,
    type: str | None = MediaType.JSON,
    **kwargs: Any,
) -> Self:
    """Link to the API root/landing page, resolved by route name."""
    return cls.model_validate(
        {
            'href': lambda ctx: ctx.url_for(landing),
            'rel': rel,
            'type': type,
            **kwargs,
        },
    )

to_route classmethod

to_route(
    name: str,
    *,
    rel: str,
    type: str | None = MediaType.JSON,
    path: dict[str, Any] | None = None,
    template: Sequence[str] | None = None,
    query_template: Sequence[str] | None = None,
    **kwargs: Any,
) -> Self

Link to a named route (resolved via ctx.url_for(name, **path)).

With template and/or query_template the link instead advertises an unbound RFC 6570 URI template (templated: true) for the client to expand: the named vars are left as {var} expressions rather than resolved.

Parameters:

Name Type Description Default
name str

The route name to resolve.

required
rel str

The link relation.

required
type str | None

The target media type.

JSON
path dict[str, Any] | None

Path parameters for the route. Bound into the deferred resolver; not stored on the link itself.

None
template Sequence[str] | None

Path-position route variables to leave unbound as RFC 6570 {var} expressions (resolved via :meth:~gazebo.context.RequestContext.url_for_template).

None
query_template Sequence[str] | None

Query variables to append as an RFC 6570 form-query expression — {?a,b} on a query-less base, or {&a,b} when the resolved base already carries a query string.

None
**kwargs Any

Extra link fields (e.g. title).

{}
Source code in src/gazebo/link.py
@classmethod
def to_route(
    cls,
    name: str,
    *,
    rel: str,
    type: str | None = MediaType.JSON,
    path: dict[str, Any] | None = None,
    template: Sequence[str] | None = None,
    query_template: Sequence[str] | None = None,
    **kwargs: Any,
) -> Self:
    """Link to a named route (resolved via ``ctx.url_for(name, **path)``).

    With ``template`` and/or ``query_template`` the link instead advertises an
    unbound RFC 6570 URI template (``templated: true``) for the client to expand:
    the named vars are left as ``{var}`` expressions rather than resolved.

    Args:
        name: The route name to resolve.
        rel: The link relation.
        type: The target media type.
        path: Path parameters for the route. Bound into the deferred
            resolver; not stored on the link itself.
        template: Path-position route variables to leave unbound as RFC 6570
            ``{var}`` expressions (resolved via
            :meth:`~gazebo.context.RequestContext.url_for_template`).
        query_template: Query variables to append as an RFC 6570 form-query
            expression — ``{?a,b}`` on a query-less base, or ``{&a,b}`` when the
            resolved base already carries a query string.
        **kwargs: Extra link fields (e.g. ``title``).
    """
    path = path or {}
    templated = bool(template) or bool(query_template)

    def resolve(ctx: RequestContext) -> str:
        if template:
            base = ctx.url_for_template(name, path, template)
        else:
            base = ctx.url_for(name, **path)
        if query_template:
            op = '&' if '?' in base else '?'
            base = f'{base}{{{op}{",".join(query_template)}}}'
        return base

    return cls.model_validate(
        {
            'href': resolve,
            'rel': rel,
            'type': type,
            'templated': True if templated else None,
            **kwargs,
        },
    )

gazebo.collection

The collection-envelope shape: items + links + counts.

Generic over the item type only. Subclasses set the serialization alias for the items field (OGC calls it features, records ...) via the items_alias class keyword. Because links are deferred, a LinkedCollection is fully constructible in business logic with no request in hand.

LinkedCollection

Bases: BaseModel

A list of T with hypermedia links and counts.

class FeatureCollection(LinkedCollection[Feature], items_alias='features'): ...

Two class keywords tune serialization, both held as class variables (so they survive generic parametrization like FeatureCollection[P] — mutating model_fields would not, as pydantic rebuilds them per specialization):

  • items_alias — the JSON key the items serialize under (OGC features / records / collections ...).
  • number_returned — set False to omit the computed numberReturned member (e.g. an OGC /collections listing, where it isn't defined).

class Collections(LinkedCollection[C], items_alias='collections', ... number_returned=False): ...

Source code in src/gazebo/collection.py
class LinkedCollection[T](BaseModel):
    """A list of ``T`` with hypermedia links and counts.

    >>> class FeatureCollection(LinkedCollection[Feature], items_alias='features'): ...

    Two class keywords tune serialization, both held as class variables (so they
    survive generic parametrization like ``FeatureCollection[P]`` — mutating
    ``model_fields`` would not, as pydantic rebuilds them per specialization):

    - ``items_alias`` — the JSON key the items serialize under (OGC ``features`` /
      ``records`` / ``collections`` ...).
    - ``number_returned`` — set ``False`` to omit the computed ``numberReturned``
      member (e.g. an OGC ``/collections`` listing, where it isn't defined).

    >>> class Collections(LinkedCollection[C], items_alias='collections',
    ...                   number_returned=False): ...
    """

    _items_alias: ClassVar[str] = 'items'
    _emit_number_returned: ClassVar[bool] = True

    @classmethod
    def _items_rename(cls) -> dict[str, str]:
        """The ``items`` -> ``items_alias`` rename mapping, or ``{}`` if unaliased.

        Shared by the serializer (applied only when ``info.by_alias``) and the JSON
        schema hook (applied unconditionally, since it only runs in serialization
        mode) so the two spellings of the same rename can't drift apart.
        """
        return {'items': cls._items_alias} if cls._items_alias != 'items' else {}

    @classmethod
    def _number_returned_names(cls) -> tuple[str, str]:
        """The ``(alias, field)`` spelling pair for the computed ``numberReturned``."""
        return ('numberReturned', 'number_returned')

    items: Sequence[T]
    links: list[Link] = Field(default_factory=list)
    number_matched: int | None = Field(default=None, serialization_alias='numberMatched')

    @computed_field(alias='numberReturned')
    @property
    def number_returned(self) -> int:
        return len(self.items)

    @model_serializer(mode='wrap', when_used='always')
    def _serialize(
        self,
        handler: SerializerFunctionWrapHandler,
        info: SerializationInfo,
    ) -> dict[str, Any]:
        # The alias and the numberReturned toggle apply in every mode (matching how a
        # plain serialization_alias would behave); the OGC-style null-dropping is for
        # the JSON wire format only, so a python-mode dump still round-trips.
        data: dict[str, Any] = handler(self)
        rename = self._items_rename()
        if info.by_alias and rename:
            data = {rename.get(k, k): v for k, v in data.items()}
        if not self._emit_number_returned:
            # The computed field carries alias='numberReturned', so the key the handler
            # emitted is determined by by_alias — pop exactly that one (same condition
            # the items rename above keys on) rather than guessing both names.
            alias_name, field_name = self._number_returned_names()
            data.pop(alias_name if info.by_alias else field_name, None)
        if info.mode == 'json':
            data = drop_none(data)
        return data

    @classmethod
    def __get_pydantic_json_schema__(
        cls,
        core_schema: CoreSchema,
        handler: GetJsonSchemaHandler,
    ) -> JsonSchemaValue:
        # Reconstruct the real (non-opaque) serialization schema, then mirror what the
        # serializer does to the JSON: rename the items key to its alias and drop the
        # numberReturned member when toggled off — applied to both ``properties`` and
        # ``required`` so OpenAPI matches the emitted body.
        json_schema = faithful_serialization_schema(core_schema, handler)
        if handler.mode != 'serialization':
            return json_schema

        rename = cls._items_rename()
        drop = set() if cls._emit_number_returned else set(cls._number_returned_names())

        properties = json_schema.get('properties')
        if isinstance(properties, dict):
            json_schema['properties'] = {
                rename.get(k, k): v for k, v in properties.items() if k not in drop
            }
        required = json_schema.get('required')
        if isinstance(required, list):
            json_schema['required'] = [
                rename.get(name, name) for name in required if name not in drop
            ]
        return json_schema

    def __init_subclass__(
        cls,
        *,
        items_alias: str | None = None,
        number_returned: bool | None = None,
        **kwargs: Any,
    ) -> None:
        super().__init_subclass__(**kwargs)
        if items_alias is not None:
            cls._items_alias = items_alias
        if number_returned is not None:
            cls._emit_number_returned = number_returned

gazebo.pagination

Pagination link helpers.

Builds next/prev (and optional first/last/self) links as deferred resolvers: at serialization they take the current request URL from the context and rewrite only the pagination query params, preserving everything else. The caller owns token semantics; gazebo only builds the links.

The builders are a thin convenience over :class:~gazebo.link.Link, not a lossy abstraction over it: every generated link can carry the full Link surface — a type, headers, a title (or any extra member, via **link_fields), and a method/body. That last pair is what makes POST pagination work on a stateless server: with method='POST' the page token rides in the request body (merged into the body you pass) rather than the query string, so each next link re-states the full search criteria the server doesn't remember.

Two flavours, both additive and framework-agnostic:

  • :func:paginatetoken-based: you supply opaque next/prev tokens (and optionally a last token). Pair it with :func:encode_cursor/:func:decode_cursor if you want a ready-made opaque cursor format instead of hand-rolling one.
  • :func:paginate_offsetoffset/limit-based: you supply the current offset, the page limit, and (optionally) the total; the first/prev/next/ last/self links are derived for you.

last_page_offset

last_page_offset(total: int, limit: int) -> int

The zero-based offset of the last page for total items at page size limit.

Zero when there are no items. limit must be positive. Shared so callers deriving their own last cursor don't re-spell the rounding math.

Source code in src/gazebo/pagination.py
def last_page_offset(total: int, limit: int) -> int:
    """The zero-based offset of the last page for ``total`` items at page size ``limit``.

    Zero when there are no items. ``limit`` must be positive. Shared so callers
    deriving their own ``last`` cursor don't re-spell the rounding math.
    """
    return ((total - 1) // limit) * limit if total > 0 else 0

paginate

paginate(
    *,
    next_token: str | None = None,
    prev_token: str | None = None,
    limit: int | None = None,
    first: bool = False,
    last_token: str | None = None,
    self_: bool = False,
    method: str = 'GET',
    body: Mapping[str, Any] | None = None,
    type: str | None = MediaType.JSON,
    headers: Mapping[str, str | list[str]] | None = None,
    token_param: str = 'token',
    limit_param: str = 'limit',
    **link_fields: Any,
) -> list[Link]

Return token-based pagination links for the values provided (deferred).

Parameters:

Name Type Description Default
next_token str | None

Token for the next page; emits a next link when set.

None
prev_token str | None

Token for the previous page; emits a prev link when set.

None
limit int | None

Page size to carry on every emitted link. When None (the default), limit_param is left out of the merge entirely, so a GET link preserves whatever limit (if any) is already on the current URL, and a POST link leaves it out of (rather than popping it from) the caller's body.

None
first bool

Emit a first link to the un-tokened first page when True.

False
last_token str | None

Token for the last page; emits a last link when set.

None
self_ bool

Emit a self link to the current request when True. Note that for method='POST' the self link's body is exactly the caller's body — it does not bake in the current page's token, so re-POSTing it returns page one unless the caller includes the current token in body.

False
method str

'GET' (default) carries the token in the query string; 'POST' carries it in the request body (merged into body) and keeps the current URL as the href — the form a stateless server needs.

'GET'
body Mapping[str, Any] | None

Base request body for method='POST' (e.g. the search criteria); the pagination params are merged into a copy of it per link.

None
type str | None

The type (target media type) set on every emitted link.

JSON
headers Mapping[str, str | list[str]] | None

Optional headers member set on every emitted link.

None
token_param str

Query/body parameter name carrying the token.

'token'
limit_param str

Query/body parameter name carrying the limit.

'limit'
**link_fields Any

Any further Link members (e.g. title) applied to every emitted link.

{}

The next/prev links lead the list (unchanged from earlier releases); any first/last/self links follow.

Source code in src/gazebo/pagination.py
def paginate(
    *,
    next_token: str | None = None,
    prev_token: str | None = None,
    limit: int | None = None,
    first: bool = False,
    last_token: str | None = None,
    self_: bool = False,
    method: str = 'GET',
    body: Mapping[str, Any] | None = None,
    type: str | None = MediaType.JSON,
    headers: Mapping[str, str | list[str]] | None = None,
    token_param: str = 'token',  # noqa: S107
    limit_param: str = 'limit',
    **link_fields: Any,
) -> list[Link]:
    """Return token-based pagination links for the values provided (deferred).

    Args:
        next_token: Token for the next page; emits a ``next`` link when set.
        prev_token: Token for the previous page; emits a ``prev`` link when set.
        limit: Page size to carry on every emitted link. When ``None`` (the default),
            ``limit_param`` is left out of the merge entirely, so a GET link preserves
            whatever limit (if any) is already on the current URL, and a POST link
            leaves it out of (rather than popping it from) the caller's ``body``.
        first: Emit a ``first`` link to the un-tokened first page when ``True``.
        last_token: Token for the last page; emits a ``last`` link when set.
        self_: Emit a ``self`` link to the current request when ``True``. Note that for
            ``method='POST'`` the ``self`` link's body is exactly the caller's ``body``
            — it does *not* bake in the current page's token, so re-POSTing it returns
            page one unless the caller includes the current token in ``body``.
        method: ``'GET'`` (default) carries the token in the query string; ``'POST'``
            carries it in the request **body** (merged into ``body``) and keeps the
            current URL as the href — the form a stateless server needs.
        body: Base request body for ``method='POST'`` (e.g. the search criteria); the
            pagination params are merged into a copy of it per link.
        type: The ``type`` (target media type) set on every emitted link.
        headers: Optional ``headers`` member set on every emitted link.
        token_param: Query/body parameter name carrying the token.
        limit_param: Query/body parameter name carrying the limit.
        **link_fields: Any further ``Link`` members (e.g. ``title``) applied to every
            emitted link.

    The ``next``/``prev`` links lead the list (unchanged from earlier releases); any
    ``first``/``last``/``self`` links follow.
    """
    style = _LinkStyle(method=method, body=body, type=type, headers=headers, extra=link_fields)

    def link(rel: str, token: str | None) -> Link:
        params: dict[str, object] = {token_param: token}
        if limit is not None:
            params[limit_param] = limit
        return _page_link(rel, params, style)

    links: list[Link] = []
    if next_token is not None:
        links.append(link(Rel.NEXT, next_token))
    if prev_token is not None:
        links.append(link(Rel.PREV, prev_token))
    if first:
        # The first page is the collection with no token; an explicit limit still
        # overrides, but an absent one leaves the current URL's limit untouched.
        first_params: dict[str, object] = {token_param: None}
        if limit is not None:
            first_params[limit_param] = limit
        links.append(_page_link(Rel.FIRST, first_params, style))
    if last_token is not None:
        links.append(link(Rel.LAST, last_token))
    if self_:
        # self repeats the current request unchanged (no token rewrite).
        links.append(_page_link(Rel.SELF, {}, style))
    return links

paginate_offset

paginate_offset(
    *,
    offset: int,
    limit: int,
    total: int | None = None,
    self_: bool = True,
    method: str = 'GET',
    body: Mapping[str, Any] | None = None,
    type: str | None = MediaType.JSON,
    headers: Mapping[str, str | list[str]] | None = None,
    offset_param: str = 'offset',
    limit_param: str = 'limit',
    **link_fields: Any,
) -> list[Link]

Return offset/limit pagination links, derived from the current page (deferred).

Emits self/first/prev/next/last as the position warrants: first/prev only when offset > 0; next whenever total is unknown or another page follows; last only when total is known and differs from the current page. Each link is canonical — it carries the explicit offset/limit.

Accepts the same method/body/type/headers/**link_fields pass-through as :func:paginate (so offset paging can also ride a POST body).

Parameters:

Name Type Description Default
offset int

The current page's zero-based item offset.

required
limit int

The page size (must be positive).

required
total int | None

Total matching items, if known; enables the last link and lets next stop at the end.

None
self_ bool

Emit a self link to the current (canonical) page when True.

True
offset_param str

Query/body parameter name carrying the offset.

'offset'
limit_param str

Query/body parameter name carrying the limit.

'limit'

Raises:

Type Description
ValueError

If limit is not positive or offset is negative.

Source code in src/gazebo/pagination.py
def paginate_offset(
    *,
    offset: int,
    limit: int,
    total: int | None = None,
    self_: bool = True,
    method: str = 'GET',
    body: Mapping[str, Any] | None = None,
    type: str | None = MediaType.JSON,
    headers: Mapping[str, str | list[str]] | None = None,
    offset_param: str = 'offset',
    limit_param: str = 'limit',
    **link_fields: Any,
) -> list[Link]:
    """Return offset/limit pagination links, derived from the current page (deferred).

    Emits ``self``/``first``/``prev``/``next``/``last`` as the position warrants:
    ``first``/``prev`` only when ``offset > 0``; ``next`` whenever ``total`` is unknown
    or another page follows; ``last`` only when ``total`` is known and differs from the
    current page. Each link is canonical — it carries the explicit ``offset``/``limit``.

    Accepts the same ``method``/``body``/``type``/``headers``/``**link_fields``
    pass-through as :func:`paginate` (so offset paging can also ride a POST body).

    Args:
        offset: The current page's zero-based item offset.
        limit: The page size (must be positive).
        total: Total matching items, if known; enables the ``last`` link and lets
            ``next`` stop at the end.
        self_: Emit a ``self`` link to the current (canonical) page when ``True``.
        offset_param: Query/body parameter name carrying the offset.
        limit_param: Query/body parameter name carrying the limit.

    Raises:
        ValueError: If ``limit`` is not positive or ``offset`` is negative.
    """
    if limit <= 0:
        raise ValueError('limit must be positive')
    if offset < 0:
        raise ValueError('offset must not be negative')

    style = _LinkStyle(method=method, body=body, type=type, headers=headers, extra=link_fields)

    def at(rel: str, page_offset: int) -> Link:
        params = {offset_param: page_offset, limit_param: limit}
        return _page_link(rel, params, style)

    links: list[Link] = []
    if self_:
        links.append(at(Rel.SELF, offset))
    if offset > 0:
        links.append(at(Rel.FIRST, 0))
        links.append(at(Rel.PREV, max(0, offset - limit)))
    if total is None or offset + limit < total:
        links.append(at(Rel.NEXT, offset + limit))
    if total is not None:
        last_offset = last_page_offset(total, limit)
        if last_offset != offset:
            links.append(at(Rel.LAST, last_offset))
    return links

encode_cursor

encode_cursor(payload: Mapping[str, Any]) -> str

Encode an arbitrary token payload as one opaque, URL-safe cursor string.

The payload (any JSON-serializable mapping — e.g. {'after_id': 42}) is serialized to compact JSON and base64url-encoded without padding, so the result is safe to drop straight into a next/prev token. Round-trips through :func:decode_cursor. The cursor is opaque, not secret — it is encoded, not signed or encrypted, so never trust it without validating the decoded contents.

Source code in src/gazebo/pagination.py
def encode_cursor(payload: Mapping[str, Any]) -> str:
    """Encode an arbitrary token ``payload`` as one opaque, URL-safe cursor string.

    The payload (any JSON-serializable mapping — e.g. ``{'after_id': 42}``) is
    serialized to compact JSON and base64url-encoded without padding, so the result
    is safe to drop straight into a ``next``/``prev`` token. Round-trips through
    :func:`decode_cursor`. The cursor is **opaque, not secret** — it is encoded, not
    signed or encrypted, so never trust it without validating the decoded contents.
    """
    raw = json.dumps(payload, separators=(',', ':'), sort_keys=True).encode('utf-8')
    return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')

decode_cursor

decode_cursor(
    token: str, *, parameter: str = 'token'
) -> dict[str, Any]

Decode a cursor produced by :func:encode_cursor back into its payload.

A malformed or non-object cursor raises :class:~gazebo.params.ParamError (which the FastAPI glue renders as a 400 problem) carrying parameter — treat a bad client-supplied cursor as a client error, not a 500. parameter defaults to 'token' to match :func:paginate's default token_param; pass the actual query/body parameter name if the caller uses a different one (e.g. 'cursor').

Source code in src/gazebo/pagination.py
def decode_cursor(token: str, *, parameter: str = 'token') -> dict[str, Any]:
    """Decode a cursor produced by :func:`encode_cursor` back into its payload.

    A malformed or non-object cursor raises :class:`~gazebo.params.ParamError` (which
    the FastAPI glue renders as a ``400`` problem) carrying ``parameter`` — treat a
    bad client-supplied cursor as a client error, not a 500. ``parameter`` defaults to
    ``'token'`` to match :func:`paginate`'s default ``token_param``; pass the actual
    query/body parameter name if the caller uses a different one (e.g. ``'cursor'``).
    """
    padded = token + '=' * (-len(token) % 4)
    try:
        raw = base64.urlsafe_b64decode(padded.encode('ascii'))
        data = json.loads(raw)
    except (ValueError, UnicodeError) as exc:
        raise ParamError(parameter, 'malformed cursor') from exc
    if not isinstance(data, dict):
        raise ParamError(parameter, 'cursor must encode an object')
    return data

gazebo.linkheader

RFC 8288 Link: header serialization for already-resolved links.

Core layer: stdlib only, no web framework. Turns a list of resolved links (the serialized link dicts gazebo already emits in a JSON body) into a single Link: header value, so non-JSON-parsing clients and crawlers can follow self/next/ prev/alternate without reading the body. The framework glue (see :mod:gazebo.ext.fastapi) installs the header from a response's top-level links; this module owns only the formatting and the deliberate narrowing that keeps the header small.

Two guards keep the header from bloating — the classic failure mode of Link: when a collection carries hundreds of per-item links:

  • A rel allow-list. Only navigational relations (:data:NAV_RELS) are emitted by default — never arbitrary or per-item rels.
  • A hard cap (:data:DEFAULT_MAX_LINKS) on how many links are serialized.

Callers that want everything can pass rels=None (no filter) and a large max_links, but the defaults are intentionally conservative.

NAV_RELS module-attribute

NAV_RELS: tuple[str, ...] = (
    'self',
    'first',
    'prev',
    'next',
    'last',
    'alternate',
    'root',
    'up',
    'collection',
    'describedby',
    'conformance',
    'service-desc',
)

The link relations safe to surface in a Link: header by default.

Navigational/hypermedia rels a client or crawler follows — deliberately not every rel a body might carry, and never per-item links. Override via the rels argument to :func:format_link_header (or the FastAPI set_link_header helper).

DEFAULT_MAX_LINKS = 25

Default ceiling on links emitted into one header, so a pathological body can't produce an oversized header that trips server/proxy header-size limits.

format_link_header(
    links: Iterable[Mapping[str, Any]],
    *,
    rels: Sequence[str] | None = NAV_RELS,
    max_links: int = DEFAULT_MAX_LINKS,
) -> str

Serialize resolved links into an RFC 8288 Link: header value.

Parameters:

Name Type Description Default
links Iterable[Mapping[str, Any]]

Resolved link mappings — each a dict with at least href and rel (the shape gazebo serializes into a JSON body). A link missing either is skipped, and a callable (unresolved) href cannot appear here.

required
rels Sequence[str] | None

The relations to include, in any order; a link whose rel is not in this set is dropped. None disables filtering (include every rel) — use with care, since that can include per-item links.

NAV_RELS
max_links int

Stop after this many links (after filtering), guarding header size.

DEFAULT_MAX_LINKS

Returns:

Type Description
str

The header value (links joined by ,), or '' when nothing qualifies —

str

callers should then omit the header entirely rather than send an empty one.

Source code in src/gazebo/linkheader.py
def format_link_header(
    links: Iterable[Mapping[str, Any]],
    *,
    rels: Sequence[str] | None = NAV_RELS,
    max_links: int = DEFAULT_MAX_LINKS,
) -> str:
    """Serialize resolved ``links`` into an RFC 8288 ``Link:`` header value.

    Args:
        links: Resolved link mappings — each a dict with at least ``href`` and
            ``rel`` (the shape gazebo serializes into a JSON body). A link missing
            either is skipped, and a callable (unresolved) href cannot appear here.
        rels: The relations to include, in any order; a link whose ``rel`` is not in
            this set is dropped. ``None`` disables filtering (include every rel) —
            use with care, since that can include per-item links.
        max_links: Stop after this many links (after filtering), guarding header size.

    Returns:
        The header value (links joined by ``, ``), or ``''`` when nothing qualifies —
        callers should then omit the header entirely rather than send an empty one.
    """
    allowed = None if rels is None else frozenset(rels)
    out: list[str] = []
    for link in links:
        if allowed is not None and link.get('rel') not in allowed:
            continue
        formatted = _format_one(link)
        if formatted is None:
            continue
        out.append(formatted)
        if len(out) >= max_links:
            break
    return ', '.join(out)

gazebo.caching

Conditional-request / caching primitives (RFC 7232 / RFC 9111).

Core layer: pydantic + stdlib only, no web framework. Provides the pure pieces a service needs to support conditional GETs — derive an ETag from a value, format/ parse HTTP dates, and evaluate If-None-Match / If-Modified-Since preconditions — leaving the request/response plumbing to the FastAPI glue (not_modified / set_cache_headers in :mod:gazebo.ext.fastapi).

ETags are weak by default (W/"…"): they are derived from a serialization of the value, which signals semantic equivalence rather than byte-for-byte identity — the honest validator strength for a hash of a JSON dump.

etag_for

etag_for(value: Any, *, weak: bool = True) -> str

Derive an ETag from value (a model, mapping, str, or bytes).

The value is reduced to canonical bytes — a pydantic model via model_dump_json(by_alias=True), anything else via sorted-key JSON — and hashed (SHA-256). The result is a quoted entity-tag, prefixed W/ when weak (the default).

Note

A model carrying deferred (callable-href) links only serializes inside an active request context; outside one, ETag such a model from its underlying data rather than the link-bearing envelope.

Source code in src/gazebo/caching.py
def etag_for(value: Any, *, weak: bool = True) -> str:
    """Derive an ``ETag`` from ``value`` (a model, mapping, str, or bytes).

    The value is reduced to canonical bytes — a pydantic model via
    ``model_dump_json(by_alias=True)``, anything else via sorted-key JSON — and hashed
    (SHA-256). The result is a quoted entity-tag, prefixed ``W/`` when ``weak`` (the
    default).

    Note:
        A model carrying deferred (callable-href) links only serializes inside an
        active request context; outside one, ETag such a model from its underlying
        data rather than the link-bearing envelope.
    """
    digest = hashlib.sha256(_canonical_bytes(value)).hexdigest()
    etag = f'"{digest}"'
    return f'W/{etag}' if weak else etag

http_date

http_date(value: datetime) -> str

Format value as an IMF-fixdate HTTP date (e.g. for Last-Modified).

Source code in src/gazebo/caching.py
def http_date(value: datetime) -> str:
    """Format ``value`` as an IMF-fixdate HTTP date (e.g. for ``Last-Modified``)."""
    if value.tzinfo is None:
        value = value.replace(tzinfo=UTC)
    return format_datetime(value, usegmt=True)

parse_http_date

parse_http_date(value: str) -> datetime | None

Parse an HTTP date header into an aware datetime (None if malformed).

Source code in src/gazebo/caching.py
def parse_http_date(value: str) -> datetime | None:
    """Parse an HTTP date header into an aware ``datetime`` (``None`` if malformed)."""
    try:
        parsed = parsedate_to_datetime(value)
    except (TypeError, ValueError):
        return None
    return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)

if_none_match_satisfied

if_none_match_satisfied(etag: str, header: str) -> bool

Whether etag matches an If-None-Match header value (weak comparison).

* matches any current entity. Per RFC 7232, If-None-Match uses the weak comparison function, so the W/ prefix is ignored on both sides. The header's comma-separated entity-tags are parsed with a quoted-string-aware pattern (rather than a plain split(',')) since RFC 7232's etagc grammar permits a literal comma inside a quoted entity-tag; a candidate that isn't a well-formed quoted entity-tag is simply not matched.

Source code in src/gazebo/caching.py
def if_none_match_satisfied(etag: str, header: str) -> bool:
    """Whether ``etag`` matches an ``If-None-Match`` header value (weak comparison).

    ``*`` matches any current entity. Per RFC 7232, ``If-None-Match`` uses the *weak*
    comparison function, so the ``W/`` prefix is ignored on both sides. The header's
    comma-separated entity-tags are parsed with a quoted-string-aware pattern (rather
    than a plain ``split(',')``) since RFC 7232's ``etagc`` grammar permits a literal
    comma inside a quoted entity-tag; a candidate that isn't a well-formed quoted
    entity-tag is simply not matched.
    """
    header = header.strip()
    if header == '*':
        return True
    current = _normalize_etag(etag)
    candidates = _ETAG_PATTERN.findall(header)
    return any(current == _normalize_etag(candidate) for candidate in candidates)

is_not_modified

is_not_modified(
    *,
    method: str = 'GET',
    etag: str | None = None,
    last_modified: datetime | None = None,
    if_none_match: str | None = None,
    if_modified_since: str | None = None,
) -> bool

Evaluate the conditional-GET preconditions; True means respond 304.

Only GET/HEAD are eligible. If-None-Match takes precedence over If-Modified-Since (which is ignored entirely when the former is present, per RFC 7232 §3.3). HTTP dates carry one-second resolution, so last_modified is truncated to whole seconds before comparison.

Source code in src/gazebo/caching.py
def is_not_modified(
    *,
    method: str = 'GET',
    etag: str | None = None,
    last_modified: datetime | None = None,
    if_none_match: str | None = None,
    if_modified_since: str | None = None,
) -> bool:
    """Evaluate the conditional-GET preconditions; ``True`` means respond ``304``.

    Only ``GET``/``HEAD`` are eligible. ``If-None-Match`` takes precedence over
    ``If-Modified-Since`` (which is ignored entirely when the former is present, per
    RFC 7232 §3.3). HTTP dates carry one-second resolution, so ``last_modified`` is
    truncated to whole seconds before comparison.
    """
    if method.upper() not in _CONDITIONAL_METHODS:
        return False
    if if_none_match is not None:
        return etag is not None and if_none_match_satisfied(etag, if_none_match)
    if if_modified_since is not None and last_modified is not None:
        since = parse_http_date(if_modified_since)
        if since is None:
            return False
        lm = (
            last_modified
            if last_modified.tzinfo is not None
            else last_modified.replace(tzinfo=UTC)
        )
        return lm.replace(microsecond=0) <= since
    return False

gazebo.context

The framework-agnostic request-metadata seam gazebo's core builds on.

The core never imports a web framework. The request metadata gazebo's core features need — URL building for deferred links, and the request headers for content negotiation — is abstracted behind the RequestContext protocol and delivered ambiently through a ContextVar (set by the framework glue) with a pydantic-serialization-context fallback for manual dumps and tests.

For URL building, link hrefs may be callables that need "the current request" to produce a URL; RequestContext also carries :meth:RequestContext.url_for_template, which resolves a route while leaving selected variables as RFC 6570 {var} expressions so a link can advertise an unbound URI template (templated: true). For content negotiation, :attr:RequestContext.headers exposes the request headers (notably Accept) so core negotiation can fall back to the ambient request with no request in hand.

RequestContext

Bases: Protocol

The framework-agnostic seam for the request metadata gazebo's core needs.

Covers both request-derived features of the core: URL building (for deferred link hrefs) and the request headers (for content negotiation). Any object structurally satisfying this (e.g. a framework request adapter) can be placed in :data:link_context; the core only ever calls these members.

Because this protocol is @runtime_checkable, every adapter must implement the whole surface below — including :meth:url_for_template and :attr:headers.

Source code in src/gazebo/context.py
@runtime_checkable
class RequestContext(Protocol):
    """The framework-agnostic seam for the request metadata gazebo's core needs.

    Covers both request-derived features of the core: URL building (for deferred link
    hrefs) and the request headers (for content negotiation). Any object structurally
    satisfying this (e.g. a framework request adapter) can be placed in
    :data:`link_context`; the core only ever calls these members.

    Because this protocol is ``@runtime_checkable``, every adapter must implement the
    whole surface below — including :meth:`url_for_template` and :attr:`headers`.
    """

    @property
    def base_url(self) -> str: ...

    @property
    def url(self) -> str: ...

    @property
    def query_params(self) -> Mapping[str, str]: ...

    @property
    def headers(self) -> Mapping[str, str]:
        """The request headers, as a case-insensitive mapping (e.g. ``Accept``)."""
        ...

    def url_for(self, name: str, /, **path: object) -> str: ...

    def url_for_template(
        self,
        name: str,
        path: Mapping[str, object],
        template: Sequence[str],
        /,
    ) -> str:
        """Resolve route ``name``, binding ``path`` and leaving ``template`` as ``{var}``.

        Binds the ``path`` params concretely while each variable in ``template`` is left
        in the returned URL as an RFC 6570 ``{var}`` expression, so a link can advertise
        an unbound URI template for the client to expand.
        """
        ...

headers property

headers: Mapping[str, str]

The request headers, as a case-insensitive mapping (e.g. Accept).

url_for_template

url_for_template(
    name: str,
    path: Mapping[str, object],
    template: Sequence[str],
) -> str

Resolve route name, binding path and leaving template as {var}.

Binds the path params concretely while each variable in template is left in the returned URL as an RFC 6570 {var} expression, so a link can advertise an unbound URI template for the client to expand.

Source code in src/gazebo/context.py
def url_for_template(
    self,
    name: str,
    path: Mapping[str, object],
    template: Sequence[str],
    /,
) -> str:
    """Resolve route ``name``, binding ``path`` and leaving ``template`` as ``{var}``.

    Binds the ``path`` params concretely while each variable in ``template`` is left
    in the returned URL as an RFC 6570 ``{var}`` expression, so a link can advertise
    an unbound URI template for the client to expand.
    """
    ...

use_context

use_context(
    ctx: RequestContext,
) -> Iterator[RequestContext]

Bind ctx as the active request context for the duration of the block.

Uses an explicit reset in finally so it is correct on every supported Python version (Token only became a context manager in 3.14).

Source code in src/gazebo/context.py
@contextmanager
def use_context(ctx: RequestContext) -> Iterator[RequestContext]:
    """Bind ``ctx`` as the active request context for the duration of the block.

    Uses an explicit ``reset`` in ``finally`` so it is correct on every supported
    Python version (``Token`` only became a context manager in 3.14).
    """
    token: Token[RequestContext | None] = link_context.set(ctx)
    try:
        yield ctx
    finally:
        link_context.reset(token)

resolve_context

resolve_context(
    info_context: Any = None,
) -> RequestContext | None

Find the active request context.

Resolution order: the :data:link_context ContextVar first, then a request/context entry in a pydantic serialization info.context mapping (the manual-dump / test escape hatch).

Source code in src/gazebo/context.py
def resolve_context(info_context: Any = None) -> RequestContext | None:
    """Find the active request context.

    Resolution order: the :data:`link_context` ContextVar first, then a
    ``request``/``context`` entry in a pydantic serialization ``info.context``
    mapping (the manual-dump / test escape hatch).
    """
    ctx = link_context.get(None)
    if ctx is not None:
        return ctx
    if isinstance(info_context, Mapping):
        candidate = info_context.get('request') or info_context.get('context')
        if candidate is not None:
            return candidate
    return None

merge_params

merge_params(
    params: dict[str, Any], overrides: Mapping[str, object]
) -> None

Merge overrides into params in place.

A None value in overrides removes the key from params; any other value sets it. Shared by :func:with_query (query-string overrides) and pagination's POST body merge, so the two identical "None removes, else sets" merges aren't re-spelled.

Source code in src/gazebo/context.py
def merge_params(params: dict[str, Any], overrides: Mapping[str, object]) -> None:
    """Merge ``overrides`` into ``params`` in place.

    A ``None`` value in ``overrides`` removes the key from ``params``; any other value
    sets it. Shared by :func:`with_query` (query-string overrides) and pagination's POST
    body merge, so the two identical "None removes, else sets" merges aren't re-spelled.
    """
    for key, value in overrides.items():
        if value is None:
            params.pop(key, None)
        else:
            params[key] = value

with_query

with_query(ctx: RequestContext, **overrides: object) -> str

Return the current URL with overrides merged into the query string.

A None value removes that parameter (every occurrence). Other values are stringified (via :func:urllib.parse.urlencode). A repeated parameter (?tag=a&tag=b) is preserved verbatim unless overridden, in which case the override's single value replaces all occurrences. The shared "derive a URL from the active context" helper behind deferred pagination and content-negotiation hrefs.

Source code in src/gazebo/context.py
def with_query(ctx: RequestContext, **overrides: object) -> str:
    """Return the current URL with ``overrides`` merged into the query string.

    A ``None`` value removes that parameter (every occurrence). Other values are
    stringified (via :func:`urllib.parse.urlencode`). A repeated parameter
    (``?tag=a&tag=b``) is preserved verbatim unless overridden, in which case the
    override's single value replaces all occurrences. The shared "derive a URL from
    the active context" helper behind deferred pagination and content-negotiation
    hrefs.
    """
    parts = urlsplit(ctx.url)
    # Collect repeated params into lists so they survive the merge; urlencode's
    # doseq re-expands them.
    query: dict[str, Any] = {}
    for key, value in parse_qsl(parts.query, keep_blank_values=True):
        if key in query:
            existing = query[key]
            if isinstance(existing, list):
                existing.append(value)
            else:
                query[key] = [existing, value]
        else:
            query[key] = value
    merge_params(query, overrides)
    return urlunsplit(parts._replace(query=urlencode(query, doseq=True)))

gazebo.requestid

Opt-in per-request-id tracking for logging.

Independent of the link context in :mod:gazebo.context: this module carries a ContextVar for a request id plus a logging.Filter that stamps log records with it. Framework glue (e.g. app middleware) sets the id per request; anything that logs during that request can then include it in its output. Core layer: stdlib only.

RequestIdFilter

Bases: Filter

Logging filter that stamps each record with the active request id.

Add to a handler/logger and reference %(request_id)s in the format. The field is always present (- when no request is active), so the format string never breaks outside a request.

Source code in src/gazebo/requestid.py
class RequestIdFilter(logging.Filter):
    """Logging filter that stamps each record with the active request id.

    Add to a handler/logger and reference ``%(request_id)s`` in the format. The
    field is always present (``-`` when no request is active), so the format
    string never breaks outside a request.
    """

    def __init__(self, name: str = '', *, default: str = '-') -> None:
        super().__init__(name)
        self._default = default

    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = request_id.get(None) or self._default
        return True

gazebo.problems

RFC 7807 / 9457 problem details.

The model is core (pydantic only); rendering it into an HTTP response lives in the framework glue. Raise :class:ProblemException from anywhere; the glue's handler turns it into an application/problem+json response.

ProblemDetail

Bases: OmitNullModel

An RFC 7807/9457 problem object. Extensions allowed.

Absent optional members (detail, instance, any None extension) are omitted on JSON serialization, per the OGC/RFC 7807 omit-null convention.

Source code in src/gazebo/problems.py
class ProblemDetail(OmitNullModel):
    """An RFC 7807/9457 problem object. Extensions allowed.

    Absent optional members (``detail``, ``instance``, any ``None`` extension) are
    omitted on JSON serialization, per the OGC/RFC 7807 omit-null convention.
    """

    model_config = ConfigDict(extra='allow')

    type: str = 'about:blank'
    title: str
    status: int
    detail: str | None = None
    instance: str | None = None

ProblemException

Bases: Exception

Raise to produce a problem response. Carries a :class:ProblemDetail.

Named like the familiar HTTPException (rather than ...Error): it's a control-flow signal to emit an HTTP response, not a programming error.

Source code in src/gazebo/problems.py
class ProblemException(Exception):  # noqa: N818
    """Raise to produce a problem response. Carries a :class:`ProblemDetail`.

    Named like the familiar ``HTTPException`` (rather than ``...Error``): it's
    a control-flow signal to emit an HTTP response, not a programming error.
    """

    def __init__(
        self,
        status: int,
        title: str | None = None,
        *,
        detail: str | None = None,
        type: str = 'about:blank',
        instance: str | None = None,
        **extensions: Any,
    ) -> None:
        self.problem = ProblemDetail(
            type=type,
            title=title or _reason(status),
            status=status,
            detail=detail,
            instance=instance,
            **extensions,
        )
        super().__init__(self.problem.detail or self.problem.title)

    @classmethod
    def from_detail(cls, problem: ProblemDetail) -> ProblemException:
        """Wrap an already-built :class:`ProblemDetail` (no field re-assembly)."""
        exc = cls.__new__(cls)
        exc.problem = problem
        Exception.__init__(exc, problem.detail or problem.title)
        return exc

    @property
    def status(self) -> int:
        return self.problem.status

from_detail classmethod

from_detail(problem: ProblemDetail) -> ProblemException

Wrap an already-built :class:ProblemDetail (no field re-assembly).

Source code in src/gazebo/problems.py
@classmethod
def from_detail(cls, problem: ProblemDetail) -> ProblemException:
    """Wrap an already-built :class:`ProblemDetail` (no field re-assembly)."""
    exc = cls.__new__(cls)
    exc.problem = problem
    Exception.__init__(exc, problem.detail or problem.title)
    return exc

ProblemType

Bases: OmitNullModel

A documented, reusable kind of problem: a stable type URI plus defaults.

Define these once and raise them by reference, so a service's error catalog lives in one place and its type URIs stop defaulting to about:blank and stay stable/linkable. The per-occurrence detail/instance (and any extension members) are supplied at the raise site::

NOT_FOUND = ProblemType(
    type='https://errors.example/not-found', title='Resource not found', status=404,
)
raise NOT_FOUND.exception(detail='plant 5 not found', instance='/plants/5')
Source code in src/gazebo/problems.py
class ProblemType(OmitNullModel):
    """A documented, reusable kind of problem: a stable ``type`` URI plus defaults.

    Define these once and raise them by reference, so a service's error catalog lives
    in one place and its ``type`` URIs stop defaulting to ``about:blank`` and stay
    stable/linkable. The per-occurrence ``detail``/``instance`` (and any extension
    members) are supplied at the raise site::

        NOT_FOUND = ProblemType(
            type='https://errors.example/not-found', title='Resource not found', status=404,
        )
        raise NOT_FOUND.exception(detail='plant 5 not found', instance='/plants/5')
    """

    model_config = ConfigDict(frozen=True)

    type: str
    title: str
    status: int
    detail: str | None = None

    def problem(
        self,
        *,
        detail: str | None = None,
        instance: str | None = None,
        **extensions: Any,
    ) -> ProblemDetail:
        """Build a :class:`ProblemDetail` for one occurrence of this problem type."""
        return ProblemDetail(
            type=self.type,
            title=self.title,
            status=self.status,
            detail=detail if detail is not None else self.detail,
            instance=instance,
            **extensions,
        )

    def exception(
        self,
        *,
        detail: str | None = None,
        instance: str | None = None,
        **extensions: Any,
    ) -> ProblemException:
        """Build a :class:`ProblemException` to ``raise`` for this problem type."""
        return ProblemException.from_detail(
            self.problem(detail=detail, instance=instance, **extensions),
        )

problem

problem(
    *,
    detail: str | None = None,
    instance: str | None = None,
    **extensions: Any,
) -> ProblemDetail

Build a :class:ProblemDetail for one occurrence of this problem type.

Source code in src/gazebo/problems.py
def problem(
    self,
    *,
    detail: str | None = None,
    instance: str | None = None,
    **extensions: Any,
) -> ProblemDetail:
    """Build a :class:`ProblemDetail` for one occurrence of this problem type."""
    return ProblemDetail(
        type=self.type,
        title=self.title,
        status=self.status,
        detail=detail if detail is not None else self.detail,
        instance=instance,
        **extensions,
    )

exception

exception(
    *,
    detail: str | None = None,
    instance: str | None = None,
    **extensions: Any,
) -> ProblemException

Build a :class:ProblemException to raise for this problem type.

Source code in src/gazebo/problems.py
def exception(
    self,
    *,
    detail: str | None = None,
    instance: str | None = None,
    **extensions: Any,
) -> ProblemException:
    """Build a :class:`ProblemException` to ``raise`` for this problem type."""
    return ProblemException.from_detail(
        self.problem(detail=detail, instance=instance, **extensions),
    )

ProblemRegistry

A catalog of :class:ProblemType instances, keyed by a short name.

Register a service's problem kinds once, reference them by key, and serve the whole set from a catalog endpoint so the type URIs resolve to documentation.

problems = ProblemRegistry() not_found = problems.define( ... 'not-found', type='https://errors.example/not-found', ... title='Resource not found', status=404, ... ) raise problems['not-found'].exception(detail='plant 5 not found')

Source code in src/gazebo/problems.py
class ProblemRegistry:
    """A catalog of :class:`ProblemType` instances, keyed by a short name.

    Register a service's problem kinds once, reference them by key, and serve the
    whole set from a catalog endpoint so the ``type`` URIs resolve to documentation.

    >>> problems = ProblemRegistry()
    >>> not_found = problems.define(
    ...     'not-found', type='https://errors.example/not-found',
    ...     title='Resource not found', status=404,
    ... )
    >>> raise problems['not-found'].exception(detail='plant 5 not found')
    """

    def __init__(self) -> None:
        self._types: dict[str, ProblemType] = {}

    def register(self, key: str, problem_type: ProblemType) -> ProblemType:
        """Add an already-built :class:`ProblemType` under ``key`` (returns it)."""
        if key in self._types:
            raise ValueError(f'problem type {key!r} is already registered')
        self._types[key] = problem_type
        return problem_type

    def define(
        self,
        key: str,
        *,
        type: str,
        title: str,
        status: int,
        detail: str | None = None,
    ) -> ProblemType:
        """Build and register a :class:`ProblemType` in one call (returns it)."""
        return self.register(
            key,
            ProblemType(type=type, title=title, status=status, detail=detail),
        )

    def __getitem__(self, key: str) -> ProblemType:
        return self._types[key]

    def get(self, key: str) -> ProblemType | None:
        return self._types.get(key)

    def catalog(self) -> dict[str, ProblemType]:
        """The full catalog (a copy), ready to serve from a ``/problems`` endpoint."""
        return dict(self._types)

register

register(
    key: str, problem_type: ProblemType
) -> ProblemType

Add an already-built :class:ProblemType under key (returns it).

Source code in src/gazebo/problems.py
def register(self, key: str, problem_type: ProblemType) -> ProblemType:
    """Add an already-built :class:`ProblemType` under ``key`` (returns it)."""
    if key in self._types:
        raise ValueError(f'problem type {key!r} is already registered')
    self._types[key] = problem_type
    return problem_type

define

define(
    key: str,
    *,
    type: str,
    title: str,
    status: int,
    detail: str | None = None,
) -> ProblemType

Build and register a :class:ProblemType in one call (returns it).

Source code in src/gazebo/problems.py
def define(
    self,
    key: str,
    *,
    type: str,
    title: str,
    status: int,
    detail: str | None = None,
) -> ProblemType:
    """Build and register a :class:`ProblemType` in one call (returns it)."""
    return self.register(
        key,
        ProblemType(type=type, title=title, status=status, detail=detail),
    )

catalog

catalog() -> dict[str, ProblemType]

The full catalog (a copy), ready to serve from a /problems endpoint.

Source code in src/gazebo/problems.py
def catalog(self) -> dict[str, ProblemType]:
    """The full catalog (a copy), ready to serve from a ``/problems`` endpoint."""
    return dict(self._types)

gazebo.params

Typed parsers for the standard OGC query parameters.

Core layer: pydantic + stdlib only, no web framework. These models turn the raw string values every OGC API accepts — bbox, datetime, crs — into typed, validated objects. A malformed value raises :class:ParamError, which the FastAPI glue renders as a 400 application/problem+json response (see :mod:gazebo.ext.fastapi). The framework-agnostic parse classmethods can also be called directly from any code that already has the raw string in hand.

CRS84 module-attribute

CRS84 = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'

The OGC default CRS: WGS 84 longitude/latitude (lon, lat axis order).

Same datum as EPSG:4326 but with GeoJSON's lon/lat ordering, which is why OGC API Features uses it as the default and most common allow-list entry.

BBOX_DESCRIPTION module-attribute

BBOX_DESCRIPTION = 'A bounding box, as comma-separated numbers `minx,miny,maxx,maxy` (WGS 84 longitude/latitude), or `minx,miny,minz,maxx,maxy,maxz` for a 3D box. The x axis may wrap the antimeridian (`minx` may exceed `maxx`); the y (and z) axes must be ordered `min <= max`. Malformed values return a `400` problem.'

Human-readable description of the OGC bbox query parameter.

BBOX_EXAMPLES module-attribute

BBOX_EXAMPLES = [
    '-180,-90,180,90',
    '7.0,50.0,8.0,51.0',
    '7,50,0,8,51,1000',
]

Example bbox values: global, a regional box, and a 3D box.

DATETIME_DESCRIPTION module-attribute

DATETIME_DESCRIPTION = 'An RFC 3339 instant, or a `start/end` interval where either side may be open using `..` (or empty). An instant matches a single timestamp; an interval matches a range. Malformed values return a `400` problem.'

Human-readable description of the OGC datetime query parameter.

DATETIME_EXAMPLES module-attribute

DATETIME_EXAMPLES = [
    '2020-01-01T00:00:00Z',
    '2020-01-01T00:00:00Z/2020-12-31T23:59:59Z',
    '../2020-12-31T23:59:59Z',
    '2020-01-01T00:00:00Z/..',
]

Example datetime values: instant, closed interval, open-start, open-end.

CRS_DESCRIPTION module-attribute

CRS_DESCRIPTION = 'The coordinate reference system, as a CRS URI from the supported set. Defaults to CRS84 (WGS 84 longitude/latitude) when it is allowed. A value outside the supported set returns a `400` problem.'

Human-readable description of the OGC crs/bbox-crs query parameter.

BBoxQuery module-attribute

BBoxQuery = Annotated[
    BBox | None,
    BeforeValidator(
        _parse_bbox_field, json_schema_input_type=str
    ),
    Field(
        default=None,
        description=BBOX_DESCRIPTION,
        examples=BBOX_EXAMPLES,
    ),
]

A folded bbox query field: parses to a :class:BBox (or None), fully documented.

Put it on a consumer's own pydantic query model (used as Annotated[MyQuery, Query()]); a malformed value becomes a 400 application/problem+json under a :class:GazeboApp.

DatetimeQuery module-attribute

DatetimeQuery = Annotated[
    DatetimeInterval | None,
    BeforeValidator(
        _parse_datetime_field, json_schema_input_type=str
    ),
    Field(
        default=None,
        description=DATETIME_DESCRIPTION,
        examples=DATETIME_EXAMPLES,
    ),
]

A folded datetime query field: parses to a :class:DatetimeInterval (or None).

ParamError

Bases: Exception

A query parameter failed to parse or validate.

Carries the offending parameter name and a human detail; the FastAPI glue maps it to a 400 problem, with the parameter name as an extension member. OGC treats a malformed query parameter as a client error (400), so this is deliberately distinct from request-body validation (422).

Source code in src/gazebo/params.py
class ParamError(Exception):
    """A query parameter failed to parse or validate.

    Carries the offending ``parameter`` name and a human ``detail``; the FastAPI
    glue maps it to a ``400`` problem, with the parameter name as an extension
    member. OGC treats a malformed query parameter as a client error (400), so
    this is deliberately distinct from request-*body* validation (422).
    """

    def __init__(self, parameter: str, detail: str) -> None:
        self.parameter = parameter
        self.detail = detail
        super().__init__(f'{parameter}: {detail}')

BBox

Bases: BaseModel

A bounding box: minx,miny,maxx,maxy (2D) or with minz/maxz (3D).

Parsed from the OGC bbox query value. The x axis is allowed to wrap (minx may exceed maxx to denote a box crossing the antimeridian); the y and z axes must be ordered min <= max.

Source code in src/gazebo/params.py
class BBox(BaseModel):
    """A bounding box: ``minx,miny,maxx,maxy`` (2D) or with ``minz``/``maxz`` (3D).

    Parsed from the OGC ``bbox`` query value. The x axis is allowed to wrap (``minx``
    may exceed ``maxx`` to denote a box crossing the antimeridian); the y and z axes
    must be ordered ``min <= max``.
    """

    minx: float
    miny: float
    maxx: float
    maxy: float
    minz: float | None = None
    maxz: float | None = None

    @model_validator(mode='after')
    def _check_order(self) -> BBox:
        if self.miny > self.maxy:
            raise ParamError('bbox', 'miny must not exceed maxy')
        if self.minz is not None and self.maxz is not None and self.minz > self.maxz:
            raise ParamError('bbox', 'minz must not exceed maxz')
        return self

    def contains(self, lon: float, lat: float) -> bool:
        """Whether the point ``(lon, lat)`` falls within this (2D) box.

        Handles the antimeridian case the box itself allows: when ``minx > maxx`` the
        x extent wraps across +/-180, so a longitude matches if it is east of ``minx``
        *or* west of ``maxx``. The y axis is a plain inclusive range. The z extent (if
        any) is not considered — this is a horizontal point-in-box test.
        """
        if not (self.miny <= lat <= self.maxy):
            return False
        if self.minx <= self.maxx:
            return self.minx <= lon <= self.maxx
        return lon >= self.minx or lon <= self.maxx

    @classmethod
    def parse(cls, raw: str) -> BBox:
        """Parse a ``bbox`` query value (4 or 6 comma-separated numbers)."""
        parts = [p.strip() for p in raw.split(',')]
        if len(parts) not in (4, 6):
            raise ParamError(
                'bbox',
                f'expected 4 or 6 comma-separated numbers, got {len(parts)}',
            )
        try:
            nums = [float(p) for p in parts]
        except ValueError:
            raise ParamError('bbox', 'all bbox values must be numbers') from None
        if not all(math.isfinite(n) for n in nums):
            # reject inf/nan: float() accepts them, and nan slips past ordering checks
            raise ParamError('bbox', 'bbox values must be finite numbers')
        if len(nums) == 4:
            minx, miny, maxx, maxy = nums
            return cls(minx=minx, miny=miny, maxx=maxx, maxy=maxy)
        minx, miny, minz, maxx, maxy, maxz = nums
        return cls(minx=minx, miny=miny, maxx=maxx, maxy=maxy, minz=minz, maxz=maxz)

contains

contains(lon: float, lat: float) -> bool

Whether the point (lon, lat) falls within this (2D) box.

Handles the antimeridian case the box itself allows: when minx > maxx the x extent wraps across +/-180, so a longitude matches if it is east of minx or west of maxx. The y axis is a plain inclusive range. The z extent (if any) is not considered — this is a horizontal point-in-box test.

Source code in src/gazebo/params.py
def contains(self, lon: float, lat: float) -> bool:
    """Whether the point ``(lon, lat)`` falls within this (2D) box.

    Handles the antimeridian case the box itself allows: when ``minx > maxx`` the
    x extent wraps across +/-180, so a longitude matches if it is east of ``minx``
    *or* west of ``maxx``. The y axis is a plain inclusive range. The z extent (if
    any) is not considered — this is a horizontal point-in-box test.
    """
    if not (self.miny <= lat <= self.maxy):
        return False
    if self.minx <= self.maxx:
        return self.minx <= lon <= self.maxx
    return lon >= self.minx or lon <= self.maxx

parse classmethod

parse(raw: str) -> BBox

Parse a bbox query value (4 or 6 comma-separated numbers).

Source code in src/gazebo/params.py
@classmethod
def parse(cls, raw: str) -> BBox:
    """Parse a ``bbox`` query value (4 or 6 comma-separated numbers)."""
    parts = [p.strip() for p in raw.split(',')]
    if len(parts) not in (4, 6):
        raise ParamError(
            'bbox',
            f'expected 4 or 6 comma-separated numbers, got {len(parts)}',
        )
    try:
        nums = [float(p) for p in parts]
    except ValueError:
        raise ParamError('bbox', 'all bbox values must be numbers') from None
    if not all(math.isfinite(n) for n in nums):
        # reject inf/nan: float() accepts them, and nan slips past ordering checks
        raise ParamError('bbox', 'bbox values must be finite numbers')
    if len(nums) == 4:
        minx, miny, maxx, maxy = nums
        return cls(minx=minx, miny=miny, maxx=maxx, maxy=maxy)
    minx, miny, minz, maxx, maxy, maxz = nums
    return cls(minx=minx, miny=miny, maxx=maxx, maxy=maxy, minz=minz, maxz=maxz)

DatetimeInterval

Bases: BaseModel

An RFC 3339 instant or interval, as accepted by the OGC datetime param.

start/end of None denote an open (unbounded) end. An instant (a single timestamp with no /) is represented as start == end.

Source code in src/gazebo/params.py
class DatetimeInterval(BaseModel):
    """An RFC 3339 instant or interval, as accepted by the OGC ``datetime`` param.

    ``start``/``end`` of ``None`` denote an open (unbounded) end. An instant
    (a single timestamp with no ``/``) is represented as ``start == end``.
    """

    start: datetime | None = None
    end: datetime | None = None

    @model_validator(mode='after')
    def _check_order(self) -> DatetimeInterval:
        if self.start is not None and self.end is not None and self.start > self.end:
            raise ParamError('datetime', 'interval start is after end')
        return self

    @property
    def is_instant(self) -> bool:
        return self.start is not None and self.start == self.end

    def contains(self, when: datetime) -> bool:
        """Whether ``when`` falls within the (possibly half-open) interval.

        A naive ``when`` (or naive bound) is treated as UTC, so this never raises a
        naive-vs-aware ``TypeError`` regardless of how the interval was built.
        """
        when = _as_utc(when)
        if self.start is not None and when < _as_utc(self.start):
            return False
        return not (self.end is not None and when > _as_utc(self.end))

    @classmethod
    def parse(cls, raw: str) -> DatetimeInterval:
        """Parse a ``datetime`` query value (an instant or a ``start/end`` interval)."""
        raw = raw.strip()
        if '/' in raw:
            start_s, _, end_s = raw.partition('/')
            start = _parse_instant(start_s, 'datetime')
            end = _parse_instant(end_s, 'datetime')
            if start is None and end is None:
                raise ParamError('datetime', 'interval cannot be open at both ends')
            return cls(start=start, end=end)
        instant = _parse_instant(raw, 'datetime')
        if instant is None:
            raise ParamError('datetime', 'datetime value cannot be empty')
        return cls(start=instant, end=instant)

contains

contains(when: datetime) -> bool

Whether when falls within the (possibly half-open) interval.

A naive when (or naive bound) is treated as UTC, so this never raises a naive-vs-aware TypeError regardless of how the interval was built.

Source code in src/gazebo/params.py
def contains(self, when: datetime) -> bool:
    """Whether ``when`` falls within the (possibly half-open) interval.

    A naive ``when`` (or naive bound) is treated as UTC, so this never raises a
    naive-vs-aware ``TypeError`` regardless of how the interval was built.
    """
    when = _as_utc(when)
    if self.start is not None and when < _as_utc(self.start):
        return False
    return not (self.end is not None and when > _as_utc(self.end))

parse classmethod

parse(raw: str) -> DatetimeInterval

Parse a datetime query value (an instant or a start/end interval).

Source code in src/gazebo/params.py
@classmethod
def parse(cls, raw: str) -> DatetimeInterval:
    """Parse a ``datetime`` query value (an instant or a ``start/end`` interval)."""
    raw = raw.strip()
    if '/' in raw:
        start_s, _, end_s = raw.partition('/')
        start = _parse_instant(start_s, 'datetime')
        end = _parse_instant(end_s, 'datetime')
        if start is None and end is None:
            raise ParamError('datetime', 'interval cannot be open at both ends')
        return cls(start=start, end=end)
    instant = _parse_instant(raw, 'datetime')
    if instant is None:
        raise ParamError('datetime', 'datetime value cannot be empty')
    return cls(start=instant, end=instant)

CrsEnum

Bases: StrEnum

Base StrEnum for a folded crs/bbox-crs query field's closed CRS set.

Subclass it, spelling each allowed CRS as a member whose value is the CRS URI, then fold the subclass into a pydantic query model as a real field type (no type: ignore needed — it is an ordinary class)::

class BedCrs(CrsEnum):
    CRS84 = CRS84
    WEB_MERCATOR = 'http://www.opengis.net/def/crs/EPSG/0/3857'

class BedQuery(BaseModel):
    crs: BedCrs = BedCrs.CRS84

Because members are real strings (the URIs), the field value is usable downstream as a URI and serializes as one. Pydantic validates membership natively: an unknown value is a ValidationError that a :class:~gazebo.ext.fastapi.GazeboApp renders as a 400 application/problem+json citing the parameter. FastAPI renders the field as an enum query param, and the base carries :data:CRS_DESCRIPTION so it self-documents in the generated OpenAPI. Give the field a default (typically CRS84) so an absent value resolves to it.

Source code in src/gazebo/params.py
class CrsEnum(StrEnum):
    """Base ``StrEnum`` for a folded ``crs``/``bbox-crs`` query field's closed CRS set.

    Subclass it, spelling each allowed CRS as a member whose value is the CRS URI, then
    fold the subclass into a pydantic query model as a real field type (no ``type: ignore``
    needed — it is an ordinary class)::

        class BedCrs(CrsEnum):
            CRS84 = CRS84
            WEB_MERCATOR = 'http://www.opengis.net/def/crs/EPSG/0/3857'

        class BedQuery(BaseModel):
            crs: BedCrs = BedCrs.CRS84

    Because members are real strings (the URIs), the field value is usable downstream as a
    URI and serializes as one. Pydantic validates membership natively: an unknown value is
    a ``ValidationError`` that a :class:`~gazebo.ext.fastapi.GazeboApp` renders as a `400`
    ``application/problem+json`` citing the parameter. FastAPI renders the field as an
    ``enum`` query param, and the base carries :data:`CRS_DESCRIPTION` so it self-documents
    in the generated OpenAPI. Give the field a default (typically ``CRS84``) so an absent
    value resolves to it.
    """

    @classmethod
    def __get_pydantic_json_schema__(
        cls,
        core_schema: CoreSchema,
        handler: GetJsonSchemaHandler,
    ) -> JsonSchemaValue:
        # Force the shared OGC description onto the enum's schema so a folded field
        # self-documents in OpenAPI with the *parameter's* meaning. FastAPI reads the
        # parameter description from the (possibly $ref'd) enum schema, so it must live on
        # the schema itself, not only on a Field(...) the consumer would otherwise add. We
        # overwrite rather than setdefault: pydantic pre-fills ``description`` from the
        # subclass docstring (developer-facing), but the API parameter wants CRS_DESCRIPTION.
        schema = handler(core_schema)
        schema['description'] = CRS_DESCRIPTION
        return schema

validate_crs

validate_crs(
    value: str | None,
    allowed: tuple[str, ...],
    *,
    parameter: str = 'crs',
    default: str | None = CRS84,
) -> str

Resolve and validate a crs/bbox-crs URI against an allow-list.

A present value must be in allowed (the OGC conformance requirement); otherwise raises :class:ParamError (-> 400). When value is unset it resolves to default — which must itself be in allowed. Pass default=None to require the parameter when there is no safe default: an absent value then raises :class:ParamError rather than assuming one.

Raises :class:ValueError if a non-None default is not in allowed (a server misconfiguration, not bad client input).

Source code in src/gazebo/params.py
def validate_crs(
    value: str | None,
    allowed: tuple[str, ...],
    *,
    parameter: str = 'crs',
    default: str | None = CRS84,
) -> str:
    """Resolve and validate a ``crs``/``bbox-crs`` URI against an allow-list.

    A present ``value`` must be in ``allowed`` (the OGC conformance requirement);
    otherwise raises :class:`ParamError` (-> 400). When ``value`` is unset it
    resolves to ``default`` — which must itself be in ``allowed``. Pass
    ``default=None`` to require the parameter when there is no safe default: an
    absent value then raises :class:`ParamError` rather than assuming one.

    Raises :class:`ValueError` if a non-``None`` ``default`` is not in ``allowed``
    (a server misconfiguration, not bad client input).
    """
    if value is None:
        if default is None:
            allowed_list = ', '.join(allowed)
            raise ParamError(
                parameter,
                f'{parameter} is required (no default CRS); one of: {allowed_list}',
            )
        if default not in allowed:
            raise ValueError(f'crs default {default!r} is not in allowed')
        return default
    if value not in allowed:
        allowed_list = ', '.join(allowed)
        raise ParamError(parameter, f'unsupported crs {value!r}; allowed: {allowed_list}')
    return value

gazebo.negotiation

Content negotiation: resolve a representation from ?f= then Accept.

Core layer: pydantic + stdlib only, no web framework. OGC APIs let a client pick a representation with a ?f=json|html query parameter (which takes precedence) and fall back to the HTTP Accept header. This module owns that resolution — given the representations a resource offers, pick one — plus the alternate links that point at the others. It ships no HTML/templating opinion: rendering a chosen representation is the caller's job (a callable or template hook); gazebo only tells you which one and links the rest.

The representation set is also the single source for OpenAPI: :func:openapi_responses projects it into a response content map so every negotiated media type is documented from the same list that drives resolution and alternate links.

Resolution order (per OGC API Common):

  1. ?f= — an explicit format key wins. An unknown key is a client error (:class:~gazebo.params.ParamError400).
  2. Accept — standard HTTP negotiation over the offered media types. When an Accept is present but nothing it lists is on offer, that's a 406 (:class:~gazebo.problems.ProblemException).
  3. Otherwise the default (or the first offered representation).

Both error types already have handlers in the FastAPI glue, so a failed negotiation renders as application/problem+json with no extra wiring.

F_DESCRIPTION module-attribute

F_DESCRIPTION = 'The requested output format, as one of the supported representation keys. Takes precedence over the `Accept` header; an unsupported value returns a `400` problem.'

Human-readable, format-neutral description of the OGC f (format) query parameter.

It deliberately names no specific key; use :func:f_description to append the actual supported keys where they are known (a folded :class:FormatEnum, the Negotiate adapter's representations).

Representation dataclass

One representation a resource offers: a ?f= key and its media type.

Source code in src/gazebo/negotiation.py
@dataclass(frozen=True, slots=True)
class Representation:
    """One representation a resource offers: a ``?f=`` key and its media type."""

    key: str
    media_type: str

FormatEnum

Bases: StrEnum

Base StrEnum for a folded ?f= (format) query field's closed key set.

Subclass it, spelling each supported output format as a member of key, media_type: the value is the ?f= key and media_type is what that key serves. Then fold the subclass into a pydantic query model as a real field type (no type: ignore needed — it is an ordinary class)::

class BedFormat(FormatEnum):
    json = 'json', 'application/json'
    html = 'html', 'text/html'

class BedQuery(BaseModel):
    f: BedFormat = BedFormat.json

Pydantic validates membership by the key (member value): an unknown ?f= is a ValidationError that a :class:~gazebo.ext.fastapi.GazeboApp renders as a 400 application/problem+json citing the parameter. FastAPI renders the field as an enum of keys, and the base injects an OpenAPI description that names the subclass's actual ?f= keys (via :func:f_description) so it self-documents. Give the field a plain default so an absent ?f= resolves to it.

Because each member carries its media type, :attr:representation turns a chosen member into a :class:Representation with no external mapping — drive rendering and :func:alternate_links straight off the member. For Accept-aware negotiation (?f=Accept → default, and its 406), make the field an optional member (f: MyFormat | None = None) and add a one-line negotiate(MyFormat.representations(), f=query.f) call in the handler — :func:negotiate reads the request's Accept from the ambient context, so no header wrangling is needed.

Source code in src/gazebo/negotiation.py
class FormatEnum(StrEnum):
    """Base ``StrEnum`` for a folded ``?f=`` (format) query field's closed key set.

    Subclass it, spelling each supported output format as a member of ``key, media_type``:
    the *value* is the ``?f=`` key and ``media_type`` is what that key serves. Then fold
    the subclass into a pydantic query model as a real field type (no ``type: ignore``
    needed — it is an ordinary class)::

        class BedFormat(FormatEnum):
            json = 'json', 'application/json'
            html = 'html', 'text/html'

        class BedQuery(BaseModel):
            f: BedFormat = BedFormat.json

    Pydantic validates membership by the *key* (member value): an unknown ``?f=`` is a
    ``ValidationError`` that a :class:`~gazebo.ext.fastapi.GazeboApp` renders as a `400`
    ``application/problem+json`` citing the parameter. FastAPI renders the field as an
    ``enum`` of keys, and the base injects an OpenAPI description that names the
    subclass's actual ``?f=`` keys (via :func:`f_description`) so it self-documents. Give
    the field a plain default so an absent ``?f=`` resolves to it.

    Because each member carries its media type, :attr:`representation` turns a chosen
    member into a :class:`Representation` with no external mapping — drive rendering and
    :func:`alternate_links` straight off the member. For ``Accept``-aware negotiation
    (``?f=`` → ``Accept`` → default, and its `406`), make the field an **optional**
    member (``f: MyFormat | None = None``) and add a one-line
    ``negotiate(MyFormat.representations(), f=query.f)`` call in the handler —
    :func:`negotiate` reads the request's ``Accept`` from the ambient context, so no
    header wrangling is needed.
    """

    media_type: str

    def __new__(cls, key: str, media_type: str) -> FormatEnum:
        obj = str.__new__(cls, key)
        obj._value_ = key
        obj.media_type = media_type
        return obj

    @property
    def representation(self) -> Representation:
        """The :class:`Representation` (``?f=`` key + media type) this member serves."""
        return Representation(self.value, self.media_type)

    @classmethod
    def representations(cls) -> list[Representation]:
        """Every member's :class:`Representation`, in definition (server-preferred) order.

        The ``available`` list for :func:`negotiate` straight off the enum, so a folded
        field negotiates with ``negotiate(MyFormat.representations(), f=query.f)``.
        """
        return [member.representation for member in cls]

    @classmethod
    def openapi_responses(cls, **kwargs: Any) -> dict[int | str, dict[str, Any]]:
        """:func:`openapi_responses` over this enum's :meth:`representations`.

        So a folded field documents every negotiated media type straight off the enum;
        ``kwargs`` are forwarded to :func:`openapi_responses` (``status``, ``schemas``,
        ``default_schema``).
        """
        return openapi_responses(cls.representations(), **kwargs)

    @classmethod
    def __get_pydantic_json_schema__(
        cls,
        core_schema: CoreSchema,
        handler: GetJsonSchemaHandler,
    ) -> JsonSchemaValue:
        # Force the shared OGC description onto the enum's schema so a folded field
        # self-documents in OpenAPI (FastAPI reads the parameter description from the
        # enum's own schema, not only from a consumer Field(...)). We overwrite rather than
        # setdefault: pydantic pre-fills ``description`` from the subclass docstring, but
        # the API parameter wants the OGC one. The subclass knows its own members, so name
        # the actual ``?f=`` keys rather than a hardcoded example.
        schema = handler(core_schema)
        schema['description'] = f_description(member.value for member in cls)
        return schema

representation property

representation: Representation

The :class:Representation (?f= key + media type) this member serves.

representations classmethod

representations() -> list[Representation]

Every member's :class:Representation, in definition (server-preferred) order.

The available list for :func:negotiate straight off the enum, so a folded field negotiates with negotiate(MyFormat.representations(), f=query.f).

Source code in src/gazebo/negotiation.py
@classmethod
def representations(cls) -> list[Representation]:
    """Every member's :class:`Representation`, in definition (server-preferred) order.

    The ``available`` list for :func:`negotiate` straight off the enum, so a folded
    field negotiates with ``negotiate(MyFormat.representations(), f=query.f)``.
    """
    return [member.representation for member in cls]

openapi_responses classmethod

openapi_responses(
    **kwargs: Any,
) -> dict[int | str, dict[str, Any]]

:func:openapi_responses over this enum's :meth:representations.

So a folded field documents every negotiated media type straight off the enum; kwargs are forwarded to :func:openapi_responses (status, schemas, default_schema).

Source code in src/gazebo/negotiation.py
@classmethod
def openapi_responses(cls, **kwargs: Any) -> dict[int | str, dict[str, Any]]:
    """:func:`openapi_responses` over this enum's :meth:`representations`.

    So a folded field documents every negotiated media type straight off the enum;
    ``kwargs`` are forwarded to :func:`openapi_responses` (``status``, ``schemas``,
    ``default_schema``).
    """
    return openapi_responses(cls.representations(), **kwargs)

f_description

f_description(keys: Iterable[str]) -> str

:data:F_DESCRIPTION with the supported ?f= keys enumerated.

Keeps the f parameter's OpenAPI description honest about the actual formats a resource offers (e.g. json, csv) instead of a hardcoded example. Falls back to the bare :data:F_DESCRIPTION when keys is empty.

Source code in src/gazebo/negotiation.py
def f_description(keys: Iterable[str]) -> str:
    """:data:`F_DESCRIPTION` with the supported ``?f=`` ``keys`` enumerated.

    Keeps the ``f`` parameter's OpenAPI description honest about the *actual* formats a
    resource offers (e.g. ``json``, ``csv``) instead of a hardcoded example. Falls back to
    the bare :data:`F_DESCRIPTION` when ``keys`` is empty.
    """
    listed = ', '.join(f'`{key}`' for key in keys)
    if not listed:
        return F_DESCRIPTION
    return f'{F_DESCRIPTION} Supported keys: {listed}.'

openapi_responses

openapi_responses(
    available: Iterable[Representation],
    *,
    status: int | str = 200,
    schemas: Mapping[str, dict[str, Any] | None]
    | None = None,
    default_schema: dict[str, Any] | None = None,
) -> dict[int | str, dict[str, Any]]

Project available into an OpenAPI response content map, one entry per media type.

A negotiated route serves several media types (JSON from a response_model, CSV from a streaming response, ...), but FastAPI only documents the response_model's application/json. This turns the same representation list that drives :func:negotiate / :func:alternate_links into a responses= mapping so every offered media type is documented from one source. Pass it to the route (the FastAPI glue folds it in automatically for a Negotiate dependency).

Parameters:

Name Type Description Default
available Iterable[Representation]

The representations to document, in server-preferred order.

required
status int | str

The HTTP status code (or its string form) the content map hangs under.

200
schemas Mapping[str, dict[str, Any] | None] | None

Per-media-type schema overrides keyed by media type. A value of None omits that media type from the map, so a response_model keeps owning it; a dict is used as that media type's schema verbatim. A media type absent from the mapping falls back to default_schema.

None
default_schema dict[str, Any] | None

The schema for any media type not named in schemas. Defaults to {'type': 'string'} (a text body such as CSV) when None.

None

Returns:

Type Description
dict[int | str, dict[str, Any]]

A {status: {"content": {media_type: {"schema": ...}, ...}}} mapping, merge-able

dict[int | str, dict[str, Any]]

into FastAPI's responses=. The common recipe for a route with a

dict[int | str, dict[str, Any]]

response_model is schemas={MediaType.JSON: None}: JSON stays owned by the

dict[int | str, dict[str, Any]]

model (its $ref is preserved) and only the extra media types (csv, ...) are

dict[int | str, dict[str, Any]]

added.

Source code in src/gazebo/negotiation.py
def openapi_responses(
    available: Iterable[Representation],
    *,
    status: int | str = 200,
    schemas: Mapping[str, dict[str, Any] | None] | None = None,
    default_schema: dict[str, Any] | None = None,
) -> dict[int | str, dict[str, Any]]:
    """Project ``available`` into an OpenAPI response content map, one entry per media type.

    A negotiated route serves several media types (JSON from a ``response_model``, CSV
    from a streaming response, ...), but FastAPI only documents the ``response_model``'s
    ``application/json``. This turns the same representation list that drives
    :func:`negotiate` / :func:`alternate_links` into a ``responses=`` mapping so every
    offered media type is documented from one source. Pass it to the route (the FastAPI
    glue folds it in automatically for a ``Negotiate`` dependency).

    Args:
        available: The representations to document, in server-preferred order.
        status: The HTTP status code (or its string form) the content map hangs under.
        schemas: Per-media-type schema overrides keyed by media type. A value of ``None``
            **omits** that media type from the map, so a ``response_model`` keeps owning
            it; a dict is used as that media type's schema verbatim. A media type absent
            from the mapping falls back to ``default_schema``.
        default_schema: The schema for any media type not named in ``schemas``. Defaults
            to ``{'type': 'string'}`` (a text body such as CSV) when ``None``.

    Returns:
        A ``{status: {"content": {media_type: {"schema": ...}, ...}}}`` mapping, merge-able
        into FastAPI's ``responses=``. The common recipe for a route with a
        ``response_model`` is ``schemas={MediaType.JSON: None}``: JSON stays owned by the
        model (its ``$ref`` is preserved) and only the extra media types (csv, ...) are
        added.
    """
    fallback = default_schema if default_schema is not None else {'type': 'string'}
    content: dict[str, dict[str, Any]] = {}
    for rep in available:
        if schemas is not None and rep.media_type in schemas:
            schema = schemas[rep.media_type]
            if schema is None:
                continue
        else:
            schema = fallback
        content[rep.media_type] = {'schema': schema}
    return {status: {'content': content}}

negotiate

negotiate(
    available: Sequence[Representation],
    *,
    f: str | None = None,
    accept: str | None = None,
    default: Representation | None = None,
    f_param: str = 'f',
) -> Representation

Resolve which of available to serve from f (wins) then accept.

Parameters:

Name Type Description Default
available Sequence[Representation]

The representations the resource offers, in server-preferred order.

required
f str | None

The ?f= query value, if any (an explicit format key).

None
accept str | None

The Accept header value, if any. When omitted (None) and an ambient :data:~gazebo.context.link_context is active, its request's Accept header is used; an explicitly-passed value always takes precedence. Passing accept='' (or a value with no acceptable range) still short-circuits to the 406/default path without the ambient read.

None
default Representation | None

The representation to serve when neither f nor accept selects one; falls back to the first of available.

None
f_param str

The query parameter name to cite in an unknown-format error.

'f'

Raises:

Type Description
ValueError

If available is empty (a server misconfiguration).

ParamError

If f names a format that is not on offer (→ 400).

ProblemException

If accept is present but lists nothing on offer (→ 406).

Source code in src/gazebo/negotiation.py
def negotiate(
    available: Sequence[Representation],
    *,
    f: str | None = None,
    accept: str | None = None,
    default: Representation | None = None,
    f_param: str = 'f',
) -> Representation:
    """Resolve which of ``available`` to serve from ``f`` (wins) then ``accept``.

    Args:
        available: The representations the resource offers, in server-preferred order.
        f: The ``?f=`` query value, if any (an explicit format key).
        accept: The ``Accept`` header value, if any. When omitted (``None``) and an
            ambient :data:`~gazebo.context.link_context` is active, its request's
            ``Accept`` header is used; an explicitly-passed value always takes
            precedence. Passing ``accept=''`` (or a value with no acceptable range)
            still short-circuits to the ``406``/``default`` path without the ambient
            read.
        default: The representation to serve when neither ``f`` nor ``accept`` selects
            one; falls back to the first of ``available``.
        f_param: The query parameter name to cite in an unknown-format error.

    Raises:
        ValueError: If ``available`` is empty (a server misconfiguration).
        ParamError: If ``f`` names a format that is not on offer (→ ``400``).
        ProblemException: If ``accept`` is present but lists nothing on offer (→ ``406``).
    """
    if not available:
        raise ValueError('negotiate requires at least one available representation')
    if accept is None:
        # No explicit Accept: fall back to the ambient request's header, if a context
        # is active. Keeps negotiate() pure when called with no context (unit tests).
        ctx = link_context.get(None)
        if ctx is not None:
            accept = ctx.headers.get('accept')
    if f is not None:
        for rep in available:
            if rep.key == f:
                return rep
        keys = ', '.join(rep.key for rep in available)
        raise ParamError(f_param, f'unsupported format {f!r}; available: {keys}')
    if accept:
        ranges = _parse_accept(accept)
        if ranges:
            scored = [
                (_accept_quality(rep.media_type, ranges), i, rep)
                for i, rep in enumerate(available)
            ]
            # highest q wins; ties fall to server-preferred order (lowest index)
            best_q, _, best = min(scored, key=lambda s: (-s[0], s[1]))
            if best_q > 0:
                return best
            offered = ', '.join(rep.media_type for rep in available)
            raise ProblemException(
                406,
                detail=f'no acceptable representation; offered: {offered}',
            )
    return default or available[0]
alternate_links(
    current: Representation,
    available: Sequence[Representation],
    *,
    f_param: str = 'f',
    rel: str = Rel.ALTERNATE,
) -> list[Link]

Build deferred alternate links to every offered representation but current.

Each link points at the current request URL with ?f= set to that representation's key, so a client on the JSON view can discover (and switch to) the HTML one, and vice versa. Pair with a normal self link for current.

Source code in src/gazebo/negotiation.py
def alternate_links(
    current: Representation,
    available: Sequence[Representation],
    *,
    f_param: str = 'f',
    rel: str = Rel.ALTERNATE,
) -> list[Link]:
    """Build deferred ``alternate`` links to every offered representation but ``current``.

    Each link points at the current request URL with ``?f=`` set to that
    representation's key, so a client on the JSON view can discover (and switch to) the
    HTML one, and vice versa. Pair with a normal ``self`` link for ``current``.
    """
    links: list[Link] = []
    for rep in available:
        if rep.key == current.key:
            continue
        links.append(
            Link(href=_f_href(rep.key, f_param), rel=rel, type=rep.media_type, title=rep.key),
        )
    return links

gazebo.filtering

Request-side CQL2 filtering: queryables, sortables, and the engine seam.

Core layer: pydantic + stdlib only. gazebo owns the OGC plumbing around filtering — the filter/filter-lang/sortby parsing, the queryables/sortables resources derived from a pydantic model, and validating that a filter only references queryable fields — while delegating CQL2 parsing/evaluation to a pluggable :class:FilterEngine. The bundled engine (:class:~gazebo.filtering.cql2.Cql2Engine, adapting cql2-rs) lives in :mod:gazebo.filtering.cql2 behind the gazebo[cql2] extra and is not imported here, so this package never pulls in the CQL2 dependency. The FastAPI FilterParam / SortByParam adapters live in :mod:gazebo.ext.fastapi.

Compiled

Bases: Protocol

An engine's parsed and validated filter expression.

matches contract: returns True/False; a referenced property that is absent or null evaluates to unknown, so the item does not match — SQL WHERE / CQL2 three-valued logic. Property names may be dotted paths (site.coord.lat) that traverse nested mappings. properties returns every referenced property name (dotted where nested), which gazebo checks against the collection's queryables.

Source code in src/gazebo/filtering/engine.py
@runtime_checkable
class Compiled(Protocol):
    """An engine's parsed and validated filter expression.

    ``matches`` contract: returns ``True``/``False``; a referenced property that is absent
    or null evaluates to *unknown*, so the item does **not** match — SQL ``WHERE`` / CQL2
    three-valued logic. Property names may be dotted paths (``site.coord.lat``) that
    traverse nested mappings. ``properties`` returns every referenced property name (dotted
    where nested), which gazebo checks against the collection's queryables.
    """

    def properties(self) -> set[str]: ...

    def matches(self, item: Mapping[str, Any]) -> bool: ...

Filter

A compiled, validated filter ready to evaluate — what a route parameter receives.

Holds the engine-native :class:Compiled expression (reachable as compiled for engine-specific features such as SQL translation) plus the resolved lang and crs. :meth:matches is the in-memory convenience; it inherits the engine's null-handling contract, so it is safe to use directly in a list comprehension over sparse data.

Source code in src/gazebo/filtering/engine.py
class Filter:
    """A compiled, validated filter ready to evaluate — what a route parameter receives.

    Holds the engine-native :class:`Compiled` expression (reachable as ``compiled`` for
    engine-specific features such as SQL translation) plus the resolved ``lang`` and
    ``crs``. :meth:`matches` is the in-memory convenience; it inherits the engine's
    null-handling contract, so it is safe to use directly in a list comprehension over
    sparse data.
    """

    def __init__(self, compiled: Compiled, lang: FilterLang, *, crs: str = CRS84) -> None:
        self.compiled = compiled
        self.lang = lang
        self.crs = crs

    def matches(self, item: Mapping[str, Any]) -> bool:
        return self.compiled.matches(item)

    def properties(self) -> set[str]:
        return self.compiled.properties()

FilterEngine

Bases: Protocol

Compiles a raw filter value into a :class:Compiled expression.

Implementations must parse and validate (some CQL2 parsers accept malformed text leniently), raising :class:FilterError on any failure.

Source code in src/gazebo/filtering/engine.py
@runtime_checkable
class FilterEngine(Protocol):
    """Compiles a raw filter value into a :class:`Compiled` expression.

    Implementations must parse **and** validate (some CQL2 parsers accept malformed text
    leniently), raising :class:`FilterError` on any failure.
    """

    def compile(self, raw: str | Mapping[str, Any], lang: FilterLang) -> Compiled: ...

FilterError

Bases: Exception

A filter failed to compile, validate, or referenced a non-queryable property.

Framework-agnostic: the FastAPI glue maps it to a 400 application/problem+json by re-raising it as a :class:~gazebo.params.ParamError for the filter parameter. Raising or catching it directly is appropriate anywhere a filter is compiled outside a request (business logic, tests).

Source code in src/gazebo/filtering/engine.py
class FilterError(Exception):
    """A filter failed to compile, validate, or referenced a non-queryable property.

    Framework-agnostic: the FastAPI glue maps it to a ``400 application/problem+json`` by
    re-raising it as a :class:`~gazebo.params.ParamError` for the ``filter`` parameter.
    Raising or catching it directly is appropriate anywhere a filter is compiled outside a
    request (business logic, tests).
    """

FilterLang

Bases: StrEnum

The CQL2 encodings gazebo understands as filter-lang values.

Source code in src/gazebo/filtering/engine.py
class FilterLang(StrEnum):
    """The CQL2 encodings gazebo understands as ``filter-lang`` values."""

    CQL2_TEXT = 'cql2-text'
    CQL2_JSON = 'cql2-json'

Direction

Bases: StrEnum

Sort direction; - in a sortby term selects :attr:DESC.

Source code in src/gazebo/filtering/models.py
class Direction(StrEnum):
    """Sort direction; ``-`` in a ``sortby`` term selects :attr:`DESC`."""

    ASC = 'asc'
    DESC = 'desc'

Queryables

Bases: _SchemaResource

The OGC queryables resource (GET /collections/{id}/queryables).

A JSON Schema whose properties are the fields a CQL2 filter may reference; :attr:names is the allow-list :func:~gazebo.filtering.queryables.validate_properties checks against. Build one from a model with :func:~gazebo.filtering.queryables.queryables_from_model.

Source code in src/gazebo/filtering/models.py
class Queryables(_SchemaResource):
    """The OGC queryables resource (``GET /collections/{id}/queryables``).

    A JSON Schema whose ``properties`` are the fields a CQL2 ``filter`` may reference;
    :attr:`names` is the allow-list :func:`~gazebo.filtering.queryables.validate_properties`
    checks against. Build one from a model with
    :func:`~gazebo.filtering.queryables.queryables_from_model`.
    """

Sort

Bases: BaseModel

One sortby term: a (possibly dotted) field name and a direction.

Source code in src/gazebo/filtering/models.py
class Sort(BaseModel):
    """One ``sortby`` term: a (possibly dotted) field name and a direction."""

    field: str
    direction: Direction = Direction.ASC

Sortables

Bases: _SchemaResource

The sortables resource (GET /collections/{id}/sortables).

A JSON Schema whose properties are the fields sortby may name. Build one with :func:~gazebo.filtering.queryables.sortables_from_model.

Source code in src/gazebo/filtering/models.py
class Sortables(_SchemaResource):
    """The sortables resource (``GET /collections/{id}/sortables``).

    A JSON Schema whose ``properties`` are the fields ``sortby`` may name. Build one with
    :func:`~gazebo.filtering.queryables.sortables_from_model`.
    """

SortBy

Bases: BaseModel

A parsed OGC/STAC sortby value: an ordered list of :class:Sort terms.

Source code in src/gazebo/filtering/models.py
class SortBy(BaseModel):
    """A parsed OGC/STAC ``sortby`` value: an ordered list of :class:`Sort` terms."""

    sorts: list[Sort] = Field(default_factory=list)

    @classmethod
    def parse(cls, raw: str, *, sortables: Iterable[str] | None = None) -> SortBy:
        """Parse a ``sortby`` query value (``+name``/``-name``/``name``, comma-separated).

        A leading ``-`` selects descending, ``+`` or no sign ascending. Fields may be
        dotted (matching flattened sortables). Raises :class:`~gazebo.params.ParamError`
        (-> 400) on an empty term, a missing field name, a duplicate field, or — when
        ``sortables`` is given — a field outside that allow-list.
        """
        allow = set(sortables) if sortables is not None else None
        sorts: list[Sort] = []
        seen: set[str] = set()
        for term in raw.split(','):
            term = term.strip()
            if not term:
                raise ParamError('sortby', f'empty sort term in {raw!r}')
            direction = Direction.ASC
            if term[0] in '+-':
                direction = Direction.DESC if term[0] == '-' else Direction.ASC
                term = term[1:].strip()
            if not term:
                raise ParamError('sortby', f'missing field name in {raw!r}')
            if term in seen:
                raise ParamError('sortby', f'duplicate sort field {term!r}')
            if allow is not None and term not in allow:
                raise ParamError('sortby', f'{term!r} is not sortable')
            seen.add(term)
            sorts.append(Sort(field=term, direction=direction))
        return cls(sorts=sorts)

    def apply[T](self, items: Sequence[T]) -> list[T]:
        """Return ``items`` stably sorted by these terms.

        Multi-key order is achieved by sorting on the least-significant term first (Python
        sort is stable). Missing/null values sort last under ascending order (hence first
        under descending). Field access is dotted, consistent with the queryables.
        """
        out = list(items)
        for sort in reversed(self.sorts):

            def key(item: T, field: str = sort.field) -> Any:
                value = _dotted_get(item, field)
                return _LAST if value is None else value

            out.sort(key=key, reverse=sort.direction is Direction.DESC)
        return out

parse classmethod

parse(
    raw: str, *, sortables: Iterable[str] | None = None
) -> SortBy

Parse a sortby query value (+name/-name/name, comma-separated).

A leading - selects descending, + or no sign ascending. Fields may be dotted (matching flattened sortables). Raises :class:~gazebo.params.ParamError (-> 400) on an empty term, a missing field name, a duplicate field, or — when sortables is given — a field outside that allow-list.

Source code in src/gazebo/filtering/models.py
@classmethod
def parse(cls, raw: str, *, sortables: Iterable[str] | None = None) -> SortBy:
    """Parse a ``sortby`` query value (``+name``/``-name``/``name``, comma-separated).

    A leading ``-`` selects descending, ``+`` or no sign ascending. Fields may be
    dotted (matching flattened sortables). Raises :class:`~gazebo.params.ParamError`
    (-> 400) on an empty term, a missing field name, a duplicate field, or — when
    ``sortables`` is given — a field outside that allow-list.
    """
    allow = set(sortables) if sortables is not None else None
    sorts: list[Sort] = []
    seen: set[str] = set()
    for term in raw.split(','):
        term = term.strip()
        if not term:
            raise ParamError('sortby', f'empty sort term in {raw!r}')
        direction = Direction.ASC
        if term[0] in '+-':
            direction = Direction.DESC if term[0] == '-' else Direction.ASC
            term = term[1:].strip()
        if not term:
            raise ParamError('sortby', f'missing field name in {raw!r}')
        if term in seen:
            raise ParamError('sortby', f'duplicate sort field {term!r}')
        if allow is not None and term not in allow:
            raise ParamError('sortby', f'{term!r} is not sortable')
        seen.add(term)
        sorts.append(Sort(field=term, direction=direction))
    return cls(sorts=sorts)

apply

apply(items: Sequence[T]) -> list[T]

Return items stably sorted by these terms.

Multi-key order is achieved by sorting on the least-significant term first (Python sort is stable). Missing/null values sort last under ascending order (hence first under descending). Field access is dotted, consistent with the queryables.

Source code in src/gazebo/filtering/models.py
def apply[T](self, items: Sequence[T]) -> list[T]:
    """Return ``items`` stably sorted by these terms.

    Multi-key order is achieved by sorting on the least-significant term first (Python
    sort is stable). Missing/null values sort last under ascending order (hence first
    under descending). Field access is dotted, consistent with the queryables.
    """
    out = list(items)
    for sort in reversed(self.sorts):

        def key(item: T, field: str = sort.field) -> Any:
            value = _dotted_get(item, field)
            return _LAST if value is None else value

        out.sort(key=key, reverse=sort.direction is Direction.DESC)
    return out

filter_conformance_classes

filter_conformance_classes(
    *, cql2_text: bool = True, cql2_json: bool = True
) -> list[str]

The conformance-class URIs a CQL2-filterable collection should declare.

Source code in src/gazebo/filtering/models.py
def filter_conformance_classes(*, cql2_text: bool = True, cql2_json: bool = True) -> list[str]:
    """The conformance-class URIs a CQL2-filterable collection should declare."""
    uris = [CONF_FILTER, CONF_FEATURES_FILTER, CONF_QUERYABLES, CONF_SORTBY]
    if cql2_text:
        uris.append(CONF_CQL2_TEXT)
    if cql2_json:
        uris.append(CONF_CQL2_JSON)
    return uris

queryables_from_model

queryables_from_model(
    model: type[BaseModel],
    *,
    id: str | None = None,
    title: str | None = None,
    additional: bool = False,
    max_depth: int = 4,
) -> Queryables

Build a :class:~gazebo.filtering.models.Queryables from a pydantic model.

Scalars (and their constraints/enums/formats) are advertised as-is; nested models are flattened to dotted accessors; geometry fields become spatial queryables; arrays advertise their item type. additional sets additionalProperties — keep it False for an honest closed allow-list. max_depth guards recursive models.

Source code in src/gazebo/filtering/queryables.py
def queryables_from_model(
    model: type[BaseModel],
    *,
    id: str | None = None,
    title: str | None = None,
    additional: bool = False,
    max_depth: int = 4,
) -> Queryables:
    """Build a :class:`~gazebo.filtering.models.Queryables` from a pydantic model.

    Scalars (and their constraints/enums/formats) are advertised as-is; nested models are
    flattened to dotted accessors; geometry fields become spatial queryables; arrays
    advertise their item type. ``additional`` sets ``additionalProperties`` — keep it
    ``False`` for an honest closed allow-list. ``max_depth`` guards recursive models.
    """
    properties, resolved_title = _build(
        model,
        title=title,
        max_depth=max_depth,
        scalars_only=False,
    )
    return Queryables(
        id=id,
        title=resolved_title,
        properties=properties,
        additional_properties=additional,
    )

sortables_from_model

sortables_from_model(
    model: type[BaseModel],
    *,
    id: str | None = None,
    title: str | None = None,
    max_depth: int = 4,
) -> Sortables

Build a :class:~gazebo.filtering.models.Sortables from a pydantic model.

Like :func:queryables_from_model but scalar-only: geometry and array fields (which have no total order) are excluded, while nested scalar leaves are still flattened.

Source code in src/gazebo/filtering/queryables.py
def sortables_from_model(
    model: type[BaseModel],
    *,
    id: str | None = None,
    title: str | None = None,
    max_depth: int = 4,
) -> Sortables:
    """Build a :class:`~gazebo.filtering.models.Sortables` from a pydantic model.

    Like :func:`queryables_from_model` but scalar-only: geometry and array fields (which
    have no total order) are excluded, while nested scalar leaves are still flattened.
    """
    properties, resolved_title = _build(
        model,
        title=title,
        max_depth=max_depth,
        scalars_only=True,
    )
    return Sortables(id=id, title=resolved_title, properties=properties)

validate_properties

validate_properties(
    filter: Filter, queryables: Queryables
) -> None

Raise :class:FilterError if filter references a non-queryable property.

The check is the filter's referenced-property set minus the queryables' declared names; dotted nested references are compared against the flattened allow-list.

Source code in src/gazebo/filtering/queryables.py
def validate_properties(filter: Filter, queryables: Queryables) -> None:
    """Raise :class:`FilterError` if ``filter`` references a non-queryable property.

    The check is the filter's referenced-property set minus the queryables' declared
    names; dotted nested references are compared against the flattened allow-list.
    """
    unknown = filter.properties() - queryables.names
    if unknown:
        listed = ', '.join(sorted(unknown))
        raise FilterError(f'filter references non-queryable properties: {listed}')

gazebo.filtering.cql2

The bundled CQL2 engine, adapting cql2-rs (the gazebo[cql2] extra).

This is the only module that imports cql2; importing it requires the extra. It is not imported by :mod:gazebo.filtering at package import, so the core stays free of the dependency. gazebo ships exactly one engine but keeps :class:~gazebo.filtering.engine's FilterEngine Protocol open, so a user who prefers another CQL2 implementation can supply their own without gazebo bundling or testing a second one.

Two cql2-rs behaviors shape this adapter:

  • Its text parser is lenient — malformed text can parse to a stray property reference rather than raising — so :meth:Cql2Engine.compile always calls validate().
  • matches raises (rather than returning False) when a referenced property is absent or null. To get SQL WHERE semantics without depending on that error message, :meth:Cql2Compiled.matches evaluates via reduce and treats only a literal True as a match (an "unknown" comparison stays a partial expression — see the method).

Cql2Compiled

A validated :class:cql2.Expr, adapting it to the :class:Compiled Protocol.

Source code in src/gazebo/filtering/cql2.py
class Cql2Compiled:
    """A validated :class:`cql2.Expr`, adapting it to the :class:`Compiled` Protocol."""

    def __init__(self, expr: cql2.Expr) -> None:
        self.native = expr
        """The underlying :class:`cql2.Expr` — for engine-specific features (``to_sql``)."""

    def properties(self) -> set[str]:
        return referenced_properties(self.native.to_json())

    def matches(self, item: Mapping[str, Any]) -> bool:
        # Reduce (rather than match()) so missing/null properties don't raise: an
        # expression that resolves collapses to a literal ``True``/``False``, while one
        # left "unknown" (an absent/null/type-mismatched property) stays a partial
        # expression. Only a literal ``True`` matches — SQL ``WHERE`` / CQL2 three-valued
        # logic — and this reads cql2-rs's documented API, not its error message.
        reduced: object = self.native.reduce(dict(item)).to_json()
        return reduced is True

native instance-attribute

native = expr

The underlying :class:cql2.Expr — for engine-specific features (to_sql).

Cql2Engine

A :class:~gazebo.filtering.engine.FilterEngine backed by cql2-rs.

Source code in src/gazebo/filtering/cql2.py
class Cql2Engine:
    """A :class:`~gazebo.filtering.engine.FilterEngine` backed by cql2-rs."""

    def compile(self, raw: str | Mapping[str, Any], lang: FilterLang) -> Compiled:
        try:
            if isinstance(raw, str):
                expr = (
                    cql2.parse_json(raw) if lang is FilterLang.CQL2_JSON else cql2.parse_text(raw)
                )
            else:
                expr = cql2.Expr(dict(raw))
            expr.validate()  # REQUIRED: the text parser accepts malformed input leniently
        except Exception as exc:
            raise FilterError(f'invalid {lang.value} filter: {exc}') from exc
        return Cql2Compiled(expr)

referenced_properties

referenced_properties(node: Any) -> set[str]

Collect every {'property': <name>} reference anywhere in a cql2-json node.

cql2-json is a serialized AST; property references — including the dotted paths used for nested fields — appear uniformly as {'property': <name>}, at any depth and inside every operator (comparison, logical, spatial, temporal, array, function).

Source code in src/gazebo/filtering/cql2.py
def referenced_properties(node: Any) -> set[str]:
    """Collect every ``{'property': <name>}`` reference anywhere in a cql2-json node.

    cql2-json *is* a serialized AST; property references — including the dotted paths used
    for nested fields — appear uniformly as ``{'property': <name>}``, at any depth and
    inside every operator (comparison, logical, spatial, temporal, array, function).
    """
    found: set[str] = set()
    if isinstance(node, dict):
        name = node.get('property')
        if isinstance(name, str):
            found.add(name)
        for value in node.values():
            found |= referenced_properties(value)
    elif isinstance(node, (list, tuple)):
        for item in node:
            found |= referenced_properties(item)
    return found

gazebo.geojson

GeoJSON models (RFC 7946) with gazebo hypermedia, for OGC API Features.

Optional extra: importing this module requires geojson-pydantic (the gazebo[geojson] extra). It reuses geojson-pydantic for the coordinate-validated geometry and feature shapes — the tedious, easy-to-get-wrong part — and layers gazebo's deferred links on top:

  • :class:Feature subclasses geojson-pydantic's Feature to add a links array.
  • :class:FeatureCollection is a :class:~gazebo.collection.LinkedCollection (so it carries links + numberReturned/numberMatched) rather than geojson-pydantic's plain collection, which has none of that. Items serialize under features and an optional top-level bbox is supported (RFC 7946 §5).

The geometry types are re-exported for convenience.

Feature

Bases: Feature[Geometry, P]

A GeoJSON Feature with OGC-style hypermedia links.

Generic over the properties model P. Inherits geojson-pydantic's coordinate validation for geometry and adds gazebo's deferred links, resolved at serialization like every other gazebo link.

Source code in src/gazebo/geojson.py
class Feature[P: BaseModel](_Feature[Geometry, P]):
    """A GeoJSON ``Feature`` with OGC-style hypermedia links.

    Generic over the ``properties`` model ``P``. Inherits geojson-pydantic's
    coordinate validation for ``geometry`` and adds gazebo's deferred ``links``,
    resolved at serialization like every other gazebo link.
    """

    type: Literal['Feature'] = 'Feature'
    links: list[Link] = Field(default_factory=list)

FeatureCollection

Bases: LinkedCollection[Feature[P]]

A GeoJSON FeatureCollection that is also a gazebo LinkedCollection.

Items serialize under features; the envelope additionally carries links, numberReturned/numberMatched (from LinkedCollection), and an optional top-level bbox.

Source code in src/gazebo/geojson.py
class FeatureCollection[P: BaseModel](LinkedCollection[Feature[P]], items_alias='features'):
    """A GeoJSON ``FeatureCollection`` that is also a gazebo ``LinkedCollection``.

    Items serialize under ``features``; the envelope additionally carries
    ``links``, ``numberReturned``/``numberMatched`` (from ``LinkedCollection``),
    and an optional top-level ``bbox``.
    """

    type: Literal['FeatureCollection'] = 'FeatureCollection'
    bbox: list[float] | None = None

gazebo.ogc

OGC API Common models: landing page, conformance.

Pure pydantic models plus a small conformance-class registry. The framework glue generates the actual landing-page links from the router tree.

DEFAULT_TRS module-attribute

DEFAULT_TRS = (
    'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian'
)

The OGC default temporal reference system (the Gregorian calendar / UTC).

LandingPage

Bases: BaseModel

OGC API Common landing page (GET /).

Source code in src/gazebo/ogc.py
class LandingPage(BaseModel):
    """OGC API Common landing page (``GET /``)."""

    model_config = ConfigDict(extra='allow')

    title: str = ''
    description: str = ''
    links: list[Link] = Field(default_factory=list)

ConformanceDeclaration

Bases: BaseModel

OGC API Common conformance declaration (GET /conformance).

Source code in src/gazebo/ogc.py
class ConformanceDeclaration(BaseModel):
    """OGC API Common conformance declaration (``GET /conformance``)."""

    conforms_to: list[str] = Field(
        default_factory=list,
        alias='conformsTo',
        serialization_alias='conformsTo',
    )

SpatialExtent

Bases: BaseModel

The spatial extent of a collection: one or more bounding boxes in crs.

Per OGC, the first bbox is the overall extent; further entries may partition it. Each bbox is [minx, miny, maxx, maxy] (or the 6-number 3D form).

Source code in src/gazebo/ogc.py
class SpatialExtent(BaseModel):
    """The spatial extent of a collection: one or more bounding boxes in ``crs``.

    Per OGC, the first bbox is the overall extent; further entries may partition it.
    Each bbox is ``[minx, miny, maxx, maxy]`` (or the 6-number 3D form).
    """

    bbox: list[list[float]] = Field(default_factory=_world_bbox)
    crs: str = CRS84

TemporalExtent

Bases: BaseModel

The temporal extent: one or more intervals in trs.

Each interval is a [start, end] pair; null on either side means open.

Source code in src/gazebo/ogc.py
class TemporalExtent(BaseModel):
    """The temporal extent: one or more intervals in ``trs``.

    Each interval is a ``[start, end]`` pair; ``null`` on either side means open.
    """

    interval: list[list[datetime | None]] = Field(default_factory=_open_interval)
    trs: str = DEFAULT_TRS

Extent

Bases: OmitNullModel

A collection's spatial and/or temporal extent.

An unset spatial/temporal is omitted on the wire rather than emitted as null (OGC treats them as optional members).

Source code in src/gazebo/ogc.py
class Extent(OmitNullModel):
    """A collection's spatial and/or temporal extent.

    An unset ``spatial``/``temporal`` is omitted on the wire rather than emitted as
    ``null`` (OGC treats them as optional members).
    """

    spatial: SpatialExtent | None = None
    temporal: TemporalExtent | None = None

Collection

Bases: OmitNullModel

OGC API Common collection metadata (GET /collections/{id}).

An unset extent is omitted on the wire rather than emitted as null.

Source code in src/gazebo/ogc.py
class Collection(OmitNullModel):
    """OGC API Common collection metadata (``GET /collections/{id}``).

    An unset ``extent`` is omitted on the wire rather than emitted as ``null``.
    """

    model_config = ConfigDict(extra='allow')

    id: str
    title: str = ''
    description: str = ''
    extent: Extent | None = None
    item_type: str = Field(default='feature', serialization_alias='itemType')
    crs: list[str] = Field(default_factory=lambda: [CRS84])
    links: list[Link] = Field(default_factory=list)

Collections

Bases: LinkedCollection[Collection]

The /collections envelope: a list of :class:Collection under collections.

Omits numberReturned — the OGC /collections object does not define it.

Source code in src/gazebo/ogc.py
class Collections(
    LinkedCollection[Collection],
    items_alias='collections',
    number_returned=False,
):
    """The ``/collections`` envelope: a list of :class:`Collection` under ``collections``.

    Omits ``numberReturned`` — the OGC ``/collections`` object does not define it.
    """

Conformance

A small registry of conformance-class URIs.

conformance = Conformance(Conformance.CORE) conformance.add(Conformance.JSON) conformance.declaration()

Source code in src/gazebo/ogc.py
class Conformance:
    """A small registry of conformance-class URIs.

    >>> conformance = Conformance(Conformance.CORE)
    >>> conformance.add(Conformance.JSON)
    >>> conformance.declaration()
    """

    # A few common OGC API Common conformance classes.
    CORE = 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core'
    LANDING_PAGE = 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page'
    JSON = 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json'
    OAS30 = 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30'
    HTML = 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html'

    def __init__(self, *uris: str) -> None:
        self._uris: list[str] = []
        self.add(*uris)

    def add(self, *uris: str) -> Conformance:
        for uri in uris:
            if uri not in self._uris:
                self._uris.append(uri)
        return self

    @property
    def uris(self) -> list[str]:
        return list(self._uris)

    def declaration(self) -> ConformanceDeclaration:
        return ConformanceDeclaration(conformsTo=self.uris)

gazebo.rels

Typed constants for link relations and media types.

Kills stringly-typed rel/type bugs. StrEnum members are str subclasses, so they drop into Link(rel=Rel.SELF, type=MediaType.JSON) and serialize as their plain string value.

Rel

Bases: StrEnum

Common IANA / OGC link relation types.

Source code in src/gazebo/rels.py
class Rel(StrEnum):
    """Common IANA / OGC link relation types."""

    SELF = 'self'
    ROOT = 'root'
    UP = 'up'
    PARENT = 'parent'
    CHILD = 'child'
    NEXT = 'next'
    PREV = 'prev'
    FIRST = 'first'
    LAST = 'last'
    COLLECTION = 'collection'
    ITEMS = 'items'
    ITEM = 'item'
    DATA = 'data'
    CONFORMANCE = 'conformance'
    SERVICE_DESC = 'service-desc'
    SERVICE_DOC = 'service-doc'
    DESCRIBEDBY = 'describedby'
    ALTERNATE = 'alternate'
    STATUS = 'status'

MediaType

Bases: StrEnum

Common media types for OGC-style APIs.

Source code in src/gazebo/rels.py
class MediaType(StrEnum):
    """Common media types for OGC-style APIs."""

    JSON = 'application/json'
    GEOJSON = 'application/geo+json'
    PROBLEM = 'application/problem+json'
    HTML = 'text/html'
    CSV = 'text/csv'
    XML = 'application/xml'
    TEXT = 'text/plain'
    OPENAPI = 'application/vnd.oai.openapi+json;version=3.0'
    OPENAPI_YAML = 'application/vnd.oai.openapi;version=3.0'

gazebo.serialization

Shared serialization helpers (pure pydantic + pydantic-core; no gazebo imports).

OGC omits absent members rather than emitting null. gazebo gets that with a JSON @model_serializer that drops null fields — but a model with a @model_serializer makes pydantic treat the serialized shape as opaque: its serialization JSON schema — and therefore FastAPI's OpenAPI response schema — collapses to {"additionalProperties": true}. The two halves must travel together, so :class:OmitNullModel bundles them: subclass it and absent optional members are omitted on the wire while the documented response schema stays honest. Models with richer serializers (e.g. :class:~gazebo.collection.LinkedCollection) reuse the pieces directly — :func:drop_none for the wire shape and :func:faithful_serialization_schema from their own __get_pydantic_json_schema__.

Why not pydantic's native exclude_none? It is a dump-time flag, not a model property — there is no model-level "always omit none" in pydantic. Relying on it would push the responsibility onto each caller (model_dump(exclude_none=True)) or, under the framework glue, onto per-route response_model_exclude_none=True — easy to forget and inert for non-FastAPI users. Baking omission into the model keeps the behavior self-contained and correct regardless of how the model is serialized.

OmitNullModel

Bases: BaseModel

A pydantic model that omits null fields on JSON serialization, OGC-style.

Subclass it for any model whose absent optional members should be omitted on the wire rather than emitted as null. It bundles the two halves that have to travel together: a JSON-mode @model_serializer that drops None values, and a __get_pydantic_json_schema__ that reconstructs the real field shape so the serializer does not opacify the OpenAPI response schema.

Source code in src/gazebo/serialization.py
class OmitNullModel(BaseModel):
    """A pydantic model that omits null fields on JSON serialization, OGC-style.

    Subclass it for any model whose absent optional members should be omitted on
    the wire rather than emitted as ``null``. It bundles the two halves that have to
    travel together: a JSON-mode ``@model_serializer`` that drops ``None`` values,
    and a ``__get_pydantic_json_schema__`` that reconstructs the real field shape so
    the serializer does not opacify the OpenAPI response schema.
    """

    @model_serializer(mode='wrap', when_used='json')
    def _omit_null(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
        return drop_none(handler(self))

    @classmethod
    def __get_pydantic_json_schema__(
        cls,
        core_schema: CoreSchema,
        handler: GetJsonSchemaHandler,
    ) -> JsonSchemaValue:
        return faithful_serialization_schema(core_schema, handler)

drop_none

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

Drop keys whose value is None — OGC omits absent members on the wire.

Source code in src/gazebo/serialization.py
def drop_none(data: dict[str, Any]) -> dict[str, Any]:
    """Drop keys whose value is ``None`` — OGC omits absent members on the wire."""
    return {k: v for k, v in data.items() if v is not None}

strip_model_serializers

strip_model_serializers(schema: Any) -> Any

Deep-copy a core schema with model-level function serializers removed.

Only the serialization entry attached to a model node — pydantic's representation of a @model_serializer — is dropped, since that is what opacifies the output shape. Field-level serializers (PlainSerializer and friends, which sit on the field's own node) and computed-field schemas are preserved, so the reconstructed schema still reflects each field's real serialized type rather than its pre-serialization one.

Source code in src/gazebo/serialization.py
def strip_model_serializers(schema: Any) -> Any:
    """Deep-copy a core schema with model-level *function* serializers removed.

    Only the ``serialization`` entry attached to a ``model`` node — pydantic's
    representation of a ``@model_serializer`` — is dropped, since that is what
    opacifies the output shape. Field-level serializers (``PlainSerializer`` and
    friends, which sit on the field's own node) and computed-field schemas are
    preserved, so the reconstructed schema still reflects each field's real
    serialized type rather than its pre-serialization one.
    """
    if isinstance(schema, dict):
        is_model = schema.get('type') == 'model'
        return {
            key: strip_model_serializers(value)
            for key, value in schema.items()
            if not (
                is_model
                and key == 'serialization'
                and isinstance(value, dict)
                and str(value.get('type', '')).startswith('function')
            )
        }
    if isinstance(schema, list):
        return [strip_model_serializers(item) for item in schema]
    return schema

faithful_serialization_schema

faithful_serialization_schema(
    core_schema: CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue

A serialization JSON schema reflecting the real fields, not an opaque object.

Call from __get_pydantic_json_schema__ on any model whose @model_serializer would otherwise opacify its serialization schema. Validation schemas (request bodies) are unaffected and returned as-is.

Source code in src/gazebo/serialization.py
def faithful_serialization_schema(
    core_schema: CoreSchema,
    handler: GetJsonSchemaHandler,
) -> JsonSchemaValue:
    """A serialization JSON schema reflecting the real fields, not an opaque object.

    Call from ``__get_pydantic_json_schema__`` on any model whose ``@model_serializer``
    would otherwise opacify its serialization schema. Validation schemas (request
    bodies) are unaffected and returned as-is.
    """
    json_schema = handler(core_schema)
    if handler.mode == 'serialization' and 'properties' not in json_schema:
        json_schema = handler(strip_model_serializers(core_schema))
    return json_schema

gazebo.tags

OpenAPI tag helpers.

Tag

Bases: BaseModel

An OpenAPI tag. external_docs serializes as externalDocs.

Source code in src/gazebo/tags.py
class Tag(BaseModel):
    """An OpenAPI tag. ``external_docs`` serializes as ``externalDocs``."""

    model_config = ConfigDict(frozen=True)

    name: str
    description: str | None = None
    external_docs: TagDocs | None = Field(
        default=None,
        serialization_alias='externalDocs',
    )

tags_metadata

tags_metadata(*tags: Tag) -> list[dict[str, Any]]

Build a list of OpenAPI tag objects (e.g. for FastAPI's openapi_tags).

Source code in src/gazebo/tags.py
def tags_metadata(*tags: Tag) -> list[dict[str, Any]]:
    """Build a list of OpenAPI tag objects (e.g. for FastAPI's ``openapi_tags``)."""
    return [tag.model_dump(mode='json', by_alias=True, exclude_none=True) for tag in tags]

gazebo.asgi

Pure-ASGI middleware — no web-framework import required.

Works with any ASGI app (Starlette, FastAPI, Litestar, Quart). Provides proxy-header normalization (with pluggable trust) and a context-setting middleware parametrized by a scope->RequestContext factory.

TrustedClient

Trust requests whose immediate client host is in an allowlist.

Source code in src/gazebo/asgi.py
class TrustedClient:
    """Trust requests whose immediate client host is in an allowlist."""

    LOOPBACK = ('127.0.0.1', '::1')

    def __init__(self, *hosts: str, loopback: bool = True) -> None:
        self.hosts = set(hosts) | (set(self.LOOPBACK) if loopback else set())

    def __call__(self, scope: Scope) -> bool:
        client = scope.get('client')
        return bool(client) and client[0] in self.hosts  # type: ignore[index]

SharedSecret

Trust requests carrying a matching shared-secret header (proxy-chain auth).

Source code in src/gazebo/asgi.py
class SharedSecret:
    """Trust requests carrying a matching shared-secret header (proxy-chain auth)."""

    def __init__(self, secret: str, *, header: str = 'x-proxy-secret') -> None:
        self._secret = secret.encode('latin-1')
        self._header = header.lower().encode('latin-1')

    def __call__(self, scope: Scope) -> bool:
        value = _get(list(scope.get('headers') or []), self._header)
        if value is None:
            return False
        return hmac.compare_digest(value, self._secret)

ProxyHeadersMiddleware

Apply X-Forwarded-{Proto,Host,Prefix} to the ASGI scope when trusted.

Supersedes uvicorn's partial --proxy-headers: it also sets the scheme from X-Forwarded-Proto (so URLs come out https behind a TLS-terminating proxy) and mutates the header list in place rather than round-tripping a dict.

Source code in src/gazebo/asgi.py
class ProxyHeadersMiddleware:
    """Apply ``X-Forwarded-{Proto,Host,Prefix}`` to the ASGI scope when trusted.

    Supersedes uvicorn's partial ``--proxy-headers``: it also sets the scheme from
    ``X-Forwarded-Proto`` (so URLs come out ``https`` behind a TLS-terminating
    proxy) and mutates the header list in place rather than round-tripping a dict.
    """

    # Forwarded protos name the HTTP scheme; a websocket scope needs the ws twin.
    _WEBSOCKET_SCHEMES: ClassVar[dict[str, str]] = {'http': 'ws', 'https': 'wss'}

    def __init__(self, app: ASGIApp, *, trust: TrustPolicy = trust_none) -> None:
        self.app = app
        self.trust = trust

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope['type'] in ('http', 'websocket') and self.trust(scope):
            self._apply(scope)
        await self.app(scope, receive, send)

    @staticmethod
    def _apply(scope: Scope) -> None:
        headers: Headers = list(scope.get('headers') or [])
        scope['headers'] = headers

        if proto := _get(headers, b'x-forwarded-proto'):
            scheme = _first(proto).decode('latin-1')
            if scope['type'] == 'websocket':
                scheme = ProxyHeadersMiddleware._WEBSOCKET_SCHEMES.get(scheme, scheme)
            scope['scheme'] = scheme

        if host := _get(headers, b'x-forwarded-host'):
            value = _first(host)
            _set(headers, b'host', value)
            name = value.split(b':')[0].decode('latin-1')
            _, port = scope.get('server') or (None, None)
            scope['server'] = (name, port)

        if prefix := _get(headers, b'x-forwarded-prefix'):
            scope['root_path'] = _first(prefix).decode('latin-1').rstrip('/')

ContextMiddleware

Set :data:~gazebo.context.link_context for each request.

factory turns the ASGI scope into a :class:RequestContext; the framework glue supplies it (e.g. wrapping the framework's request object). Use this when you are not using GazeboApp (which manages context via its request scope).

Source code in src/gazebo/asgi.py
class ContextMiddleware:
    """Set :data:`~gazebo.context.link_context` for each request.

    ``factory`` turns the ASGI ``scope`` into a :class:`RequestContext`; the
    framework glue supplies it (e.g. wrapping the framework's request object). Use this
    when you are *not* using ``GazeboApp`` (which manages context via its request
    scope).
    """

    def __init__(self, app: ASGIApp, factory: Callable[[Scope], RequestContext]) -> None:
        self.app = app
        self.factory = factory

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope['type'] != 'http':
            await self.app(scope, receive, send)
            return
        with use_context(self.factory(scope)):
            await self.app(scope, receive, send)

gazebo.testing

Pytest helpers for testing the OGC-ness of a gazebo service, declaratively.

A pytest plugin you opt into — add pytest_plugins = ['gazebo.testing'] to your top-level conftest.py (it does not auto-register, so it never imposes its fixtures on an unrelated downstream suite). Opting in also enables pytest's assertion rewriting for these helpers, so a failed assert_has_link / assert_problem gets full introspection, not just the message. Importing it requires pytest (the gazebo[test] extra).

What you get:

  • :func:assert_has_link / :func:assert_problem — envelope and problem+json assertions with descriptive failures.
  • :func:drive_pagination — follow next links to exhaustion, checking the envelope invariants on every page, with a loop guard. Works with gazebo's resolved (deferred) links directly, and with GET or POST next links.
  • opt-in fixtures: gazebo_link_context (isolate the link-context contextvar for a test) and gazebo_overrides (a fresh Overrides).

Note: the assertion helpers use bare assert, so a test run under python -O (PYTHONOPTIMIZE) strips them and they become no-ops — don't optimize your tests.

find_link(
    body: Mapping[str, Any] | Sequence[Any], rel: str
) -> dict[str, Any] | None

Return the first link in body with relation rel, or None.

body may be a serialized model (a mapping with a links array) or the links list itself.

Source code in src/gazebo/testing.py
def find_link(body: Mapping[str, Any] | Sequence[Any], rel: str) -> dict[str, Any] | None:
    """Return the first link in ``body`` with relation ``rel``, or ``None``.

    ``body`` may be a serialized model (a mapping with a ``links`` array) or the
    links list itself.
    """
    links = (body.get('links') or []) if isinstance(body, Mapping) else body
    return next((link for link in links if link.get('rel') == rel), None)
assert_has_link(
    body: Mapping[str, Any] | Sequence[Any],
    rel: str,
    *,
    type: str | None = None,
    href_suffix: str | None = None,
) -> dict[str, Any]

Assert body carries a link with rel (and optionally type/href).

Returns the matched link so callers can make further assertions.

Source code in src/gazebo/testing.py
def assert_has_link(
    body: Mapping[str, Any] | Sequence[Any],
    rel: str,
    *,
    type: str | None = None,
    href_suffix: str | None = None,
) -> dict[str, Any]:
    """Assert ``body`` carries a link with ``rel`` (and optionally ``type``/href).

    Returns the matched link so callers can make further assertions.
    """
    links = list((body.get('links') or []) if isinstance(body, Mapping) else body)
    link = find_link(links, rel)
    assert link is not None, (
        f'no link with rel={rel!r}; present rels: {[link_.get("rel") for link_ in links]}'
    )
    if type is not None:
        assert link.get('type') == type, (
            f'link rel={rel!r} has type {link.get("type")!r}, expected {type!r}'
        )
    if href_suffix is not None:
        href = link.get('href', '')
        assert href.endswith(href_suffix), (
            f'link rel={rel!r} href {href!r} does not end with {href_suffix!r}'
        )
    return link

assert_problem

assert_problem(
    response: Any,
    *,
    status: int | None = None,
    type: str | None = None,
) -> dict[str, Any]

Assert response is an RFC 7807/9457 problem (content-type and shape).

response is any object with status_code, headers, and json() (an httpx / Starlette TestClient response). Returns the parsed body.

Source code in src/gazebo/testing.py
def assert_problem(
    response: Any,
    *,
    status: int | None = None,
    type: str | None = None,
) -> dict[str, Any]:
    """Assert ``response`` is an RFC 7807/9457 problem (content-type *and* shape).

    ``response`` is any object with ``status_code``, ``headers``, and ``json()``
    (an httpx / Starlette ``TestClient`` response). Returns the parsed body.
    """
    content_type = response.headers.get('content-type', '')
    assert content_type.startswith('application/problem+json'), (
        f'content-type {content_type!r} is not application/problem+json'
    )
    body = response.json()
    # RFC 7807/9457 make `title` (and every other member) OPTIONAL; the content-type
    # above is the authoritative signal. We still require `status` as a light shape
    # check — gazebo always emits it and it's what most callers assert against.
    assert 'status' in body, f'not a problem document (missing "status"): {body!r}'
    if status is not None:
        assert response.status_code == status, f'HTTP status {response.status_code} != {status}'
        assert body['status'] == status, f'problem.status {body["status"]} != {status}'
    if type is not None:
        assert body.get('type') == type, f'problem.type {body.get("type")!r} != {type!r}'
    return body

drive_pagination

drive_pagination(
    client: Any,
    url: str,
    *,
    items_key: str,
    method: str = 'GET',
    body: Any = None,
    rel: str = 'next',
    limit: int | None = None,
    max_pages: int = 1000,
    request_kwargs: Mapping[str, Any] | None = None,
) -> list[Any]

Follow rel (next by default) links to exhaustion; return all items.

Asserts the envelope invariants on every page — numberReturned matches the item count, and (if limit is given) no page exceeds it — and guards against a runaway/looping next link. For POST-driven pagination, a body member on the next link is carried into the next request (per STAPI).

request_kwargs is forwarded to every client.request call, so an authenticated service can pass request_kwargs={'headers': {...}} without wrapping the client.

Source code in src/gazebo/testing.py
def drive_pagination(
    client: Any,
    url: str,
    *,
    items_key: str,
    method: str = 'GET',
    body: Any = None,
    rel: str = 'next',
    limit: int | None = None,
    max_pages: int = 1000,
    request_kwargs: Mapping[str, Any] | None = None,
) -> list[Any]:
    """Follow ``rel`` (``next`` by default) links to exhaustion; return all items.

    Asserts the envelope invariants on *every* page — ``numberReturned`` matches the
    item count, and (if ``limit`` is given) no page exceeds it — and guards against a
    runaway/looping ``next`` link. For POST-driven pagination, a ``body`` member on
    the ``next`` link is carried into the next request (per STAPI).

    ``request_kwargs`` is forwarded to every ``client.request`` call, so an
    authenticated service can pass ``request_kwargs={'headers': {...}}`` without
    wrapping the client.
    """
    extra = dict(request_kwargs or {})
    seen: set[str] = set()
    collected: list[Any] = []
    pages = 0

    while url:
        # POST pagination legitimately reposts the same URL with a different body,
        # so the loop marker must include the body in that case.
        marker = f'{method} {url} {body!r}' if method == 'POST' else url
        assert marker not in seen, f'pagination loop: revisited {url}'
        seen.add(marker)
        pages += 1
        assert pages <= max_pages, f'pagination exceeded max_pages={max_pages} (runaway next?)'

        response = client.request(method, url, json=body, **extra)
        assert response.status_code == 200, response.text
        page = response.json()

        assert items_key in page, f'page has no {items_key!r} key: {sorted(page)}'
        items = page[items_key]
        if limit is not None:
            assert len(items) <= limit, f'page {pages} has {len(items)} items > limit {limit}'
        if 'numberReturned' in page:
            assert page['numberReturned'] == len(items), (
                f'page {pages}: numberReturned={page["numberReturned"]} but {len(items)} items'
            )
        collected.extend(items)

        nxt = find_link(page, rel)
        url = nxt['href'] if nxt else ''
        if nxt is not None and method == 'POST':
            body = nxt.get('body', body)

    return collected
gazebo_link_context() -> Iterator[None]

Isolate the link-context contextvar for a test, so a leak can't bleed.

Opt in by requesting this fixture. It is deliberately not autouse: this plugin auto-registers wherever gazebo and pytest are installed, and forcing an autouse fixture onto every unrelated test in a downstream suite would be too intrusive. Make it autouse in your own conftest.py if you want that::

@pytest.fixture(autouse=True)
def _isolate(gazebo_link_context): ...
Source code in src/gazebo/testing.py
@pytest.fixture
def gazebo_link_context() -> Iterator[None]:
    """Isolate the link-context contextvar for a test, so a leak can't bleed.

    Opt in by requesting this fixture. It is deliberately **not** autouse: this
    plugin auto-registers wherever gazebo and pytest are installed, and forcing an
    autouse fixture onto every unrelated test in a downstream suite would be too
    intrusive. Make it autouse in your own ``conftest.py`` if you want that::

        @pytest.fixture(autouse=True)
        def _isolate(gazebo_link_context): ...
    """
    token = link_context.set(None)
    try:
        yield
    finally:
        link_context.reset(token)

gazebo_overrides

gazebo_overrides() -> Any

A fresh :class:~gazebo.di.Overrides to populate and pass to an app factory.

Source code in src/gazebo/testing.py
@pytest.fixture
def gazebo_overrides() -> Any:
    """A fresh :class:`~gazebo.di.Overrides` to populate and pass to an app factory."""
    from gazebo.di import Overrides

    return Overrides()

Dependency injection

gazebo.di

gazebo.di — a small, framework-agnostic, type-driven injection container.

Extraction-ready: depends only on the standard library, never on gazebo's OGC code or any web framework.

Recipe

Recipe = Callable[
    ...,
    T
    | Awaitable[T]
    | Iterator[T]
    | AsyncIterator[T]
    | AbstractContextManager[T]
    | AbstractAsyncContextManager[T],
]

A callable building T: sync/async function, (async) generator, or (async) CM.

Container

A configured, validated injection container.

Source code in src/gazebo/di/container.py
class Container:
    """A configured, validated injection container."""

    def __init__(
        self,
        providers: Providers,
        *,
        overrides: Overrides | None = None,
        scopes: tuple[str, ...] = ('app', 'request'),
        roots: dict[str, type] | None = None,
    ) -> None:
        self.scopes = tuple(scopes)
        self._index = {name: i for i, name in enumerate(self.scopes)}
        self.roots = dict(roots or {})
        self.bindings = self._merge(providers, overrides)
        self.deps: dict[Key, list[Dep]] = {
            key: deps_of(binding.recipe) for key, binding in self.bindings.items()
        }
        self.check()

    def _merge(self, providers: Providers, overrides: Overrides | None) -> dict[Key, Binding]:
        bindings = providers.bindings
        if overrides:
            for key, value in overrides.values.items():
                if key not in bindings:
                    raise KeyError(f'override for unbound key {key}')
                bindings[key] = Binding(key, bindings[key].scope, _override_recipe(key, value))
        return bindings

    def _scope_index(self, name: str) -> int:
        try:
            return self._index[name]
        except KeyError:
            raise UnresolvedDependencyError(f'unknown scope {name!r}') from None

    def _root_satisfies(self, typ: type, max_index: int) -> bool:
        for name, root_type in self.roots.items():
            if self._scope_index(name) <= max_index and issubclass(typ, root_type):
                return True
        return False

    def check(self) -> None:
        """Validate the graph: missing deps, scope mismatch, cycles. Raises on error."""
        for key, binding in self.bindings.items():
            b_index = self._scope_index(binding.scope)
            for dep in self.deps[key]:
                if dep.type is None:
                    if dep.has_default:
                        continue
                    raise UnresolvedDependencyError(
                        f'{key}: parameter {dep.name!r} has no resolvable type',
                    )
                dep_key = Key(dep.type, dep.qualifier)
                if dep_key in self.bindings:
                    d_index = self._scope_index(self.bindings[dep_key].scope)
                    if d_index > b_index:
                        raise ScopeMismatchError(
                            f'{binding.scope!r} recipe for {key} depends on '
                            f'{self.bindings[dep_key].scope!r}-scoped {dep_key}',
                        )
                    continue
                if self._root_satisfies(dep.type, b_index):
                    continue
                if not dep.has_default:
                    raise UnresolvedDependencyError(
                        f'{key} needs {dep.name}: {dep.type.__name__} which is not bound, '
                        f'not a scope root, and has no default',
                    )
        self._check_cycles()

    def _check_cycles(self) -> None:
        visiting: set[Key] = set()
        done: set[Key] = set()

        def visit(key: Key, path: list[Key]) -> None:
            visiting.add(key)
            for dep in self.deps[key]:
                if dep.type is None:
                    continue
                dep_key = Key(dep.type, dep.qualifier)
                if dep_key not in self.bindings:
                    continue
                if dep_key in visiting:
                    chain = ' -> '.join(str(k) for k in [*path, dep_key])
                    raise CircularDependencyError(chain)
                if dep_key not in done:
                    visit(dep_key, [*path, dep_key])
            visiting.discard(key)
            done.add(key)

        for key in self.bindings:
            if key not in done:
                visit(key, [key])

    def graph(self) -> dict[str, list[str]]:
        """Adjacency of the dependency DAG (for visualization/debugging)."""
        out: dict[str, list[str]] = {}
        for key, deps in self.deps.items():
            edges = []
            for dep in deps:
                if dep.type is None:
                    continue
                dep_key = Key(dep.type, dep.qualifier)
                known = dep_key in self.bindings
                edges.append(str(dep_key) if known else f'{dep.type.__name__}(root/external)')
            out[f'{key} [{self.bindings[key].scope}]'] = edges
        return out

    def reachable_app_keys(self, entry_types: set[type]) -> set[Key]:
        """App-scoped keys reachable from ``entry_types`` (dead-provider elimination)."""
        seen: set[Key] = set()
        targets: set[Key] = set()
        stack = [Key(t) for t in entry_types]
        while stack:
            key = stack.pop()
            if key in seen or key not in self.bindings:
                continue
            seen.add(key)
            if self.bindings[key].scope == 'app':
                targets.add(key)
            for dep in self.deps[key]:
                if dep.type is not None:
                    stack.append(Key(dep.type, dep.qualifier))
        return targets

    @asynccontextmanager
    async def open_app_scope(self, *, eager: set[type] | None = None) -> AsyncIterator[ScopeState]:
        """Enter the app scope, optionally eagerly building reachable app providers."""
        state = ScopeState(self, self.scopes[0])
        try:
            targets = (
                self.reachable_app_keys(eager)
                if eager is not None
                else {k for k, b in self.bindings.items() if b.scope == self.scopes[0]}
            )
            for key in targets:
                await state.get(key.type, key.qualifier)
            yield state
        finally:
            await state.stack.aclose()

    @asynccontextmanager
    async def open_request_scope(
        self,
        app_state: ScopeState,
        *,
        root: Any,
        name: str = 'request',
    ) -> AsyncIterator[ScopeState]:
        """Enter a request (operation) scope as a child of the app scope."""
        state = ScopeState(self, name, parent=app_state, root=root)
        try:
            yield state
        finally:
            await state.stack.aclose()

check

check() -> None

Validate the graph: missing deps, scope mismatch, cycles. Raises on error.

Source code in src/gazebo/di/container.py
def check(self) -> None:
    """Validate the graph: missing deps, scope mismatch, cycles. Raises on error."""
    for key, binding in self.bindings.items():
        b_index = self._scope_index(binding.scope)
        for dep in self.deps[key]:
            if dep.type is None:
                if dep.has_default:
                    continue
                raise UnresolvedDependencyError(
                    f'{key}: parameter {dep.name!r} has no resolvable type',
                )
            dep_key = Key(dep.type, dep.qualifier)
            if dep_key in self.bindings:
                d_index = self._scope_index(self.bindings[dep_key].scope)
                if d_index > b_index:
                    raise ScopeMismatchError(
                        f'{binding.scope!r} recipe for {key} depends on '
                        f'{self.bindings[dep_key].scope!r}-scoped {dep_key}',
                    )
                continue
            if self._root_satisfies(dep.type, b_index):
                continue
            if not dep.has_default:
                raise UnresolvedDependencyError(
                    f'{key} needs {dep.name}: {dep.type.__name__} which is not bound, '
                    f'not a scope root, and has no default',
                )
    self._check_cycles()

graph

graph() -> dict[str, list[str]]

Adjacency of the dependency DAG (for visualization/debugging).

Source code in src/gazebo/di/container.py
def graph(self) -> dict[str, list[str]]:
    """Adjacency of the dependency DAG (for visualization/debugging)."""
    out: dict[str, list[str]] = {}
    for key, deps in self.deps.items():
        edges = []
        for dep in deps:
            if dep.type is None:
                continue
            dep_key = Key(dep.type, dep.qualifier)
            known = dep_key in self.bindings
            edges.append(str(dep_key) if known else f'{dep.type.__name__}(root/external)')
        out[f'{key} [{self.bindings[key].scope}]'] = edges
    return out

reachable_app_keys

reachable_app_keys(entry_types: set[type]) -> set[Key]

App-scoped keys reachable from entry_types (dead-provider elimination).

Source code in src/gazebo/di/container.py
def reachable_app_keys(self, entry_types: set[type]) -> set[Key]:
    """App-scoped keys reachable from ``entry_types`` (dead-provider elimination)."""
    seen: set[Key] = set()
    targets: set[Key] = set()
    stack = [Key(t) for t in entry_types]
    while stack:
        key = stack.pop()
        if key in seen or key not in self.bindings:
            continue
        seen.add(key)
        if self.bindings[key].scope == 'app':
            targets.add(key)
        for dep in self.deps[key]:
            if dep.type is not None:
                stack.append(Key(dep.type, dep.qualifier))
    return targets

open_app_scope async

open_app_scope(
    *, eager: set[type] | None = None
) -> AsyncIterator[ScopeState]

Enter the app scope, optionally eagerly building reachable app providers.

Source code in src/gazebo/di/container.py
@asynccontextmanager
async def open_app_scope(self, *, eager: set[type] | None = None) -> AsyncIterator[ScopeState]:
    """Enter the app scope, optionally eagerly building reachable app providers."""
    state = ScopeState(self, self.scopes[0])
    try:
        targets = (
            self.reachable_app_keys(eager)
            if eager is not None
            else {k for k, b in self.bindings.items() if b.scope == self.scopes[0]}
        )
        for key in targets:
            await state.get(key.type, key.qualifier)
        yield state
    finally:
        await state.stack.aclose()

open_request_scope async

open_request_scope(
    app_state: ScopeState,
    *,
    root: Any,
    name: str = 'request',
) -> AsyncIterator[ScopeState]

Enter a request (operation) scope as a child of the app scope.

Source code in src/gazebo/di/container.py
@asynccontextmanager
async def open_request_scope(
    self,
    app_state: ScopeState,
    *,
    root: Any,
    name: str = 'request',
) -> AsyncIterator[ScopeState]:
    """Enter a request (operation) scope as a child of the app scope."""
    state = ScopeState(self, name, parent=app_state, root=root)
    try:
        yield state
    finally:
        await state.stack.aclose()

DIError

Bases: Exception

Base class for dependency-injection errors.

Source code in src/gazebo/di/container.py
class DIError(Exception):
    """Base class for dependency-injection errors."""

ScopeState

An entered scope: a resolution cache + teardown stack, plus parent/root.

Source code in src/gazebo/di/container.py
class ScopeState:
    """An entered scope: a resolution cache + teardown stack, plus parent/root."""

    def __init__(
        self,
        container: Container,
        name: str,
        *,
        parent: ScopeState | None = None,
        root: Any = None,
    ) -> None:
        self.container = container
        self.name = name
        self.parent = parent
        self.root = root
        self.cache: dict[Key, Any] = {}
        self.stack = AsyncExitStack()

    def health_probes(self) -> Iterator[tuple[str, Callable[[], Any]]]:
        """Yield ``(label, probe)`` for every resolved value carrying a ``__health__``.

        A value's ``__health__`` is its health-check callable (sync or async, returning
        a truthy "ok"). Only values **already resolved** in this scope are probed — for
        an app scope that is everything eagerly built at startup (see
        :meth:`Container.open_app_scope`). Exposed here so callers (e.g. the FastAPI
        health endpoint) ask the scope for its probes rather than reaching into the
        resolution cache.
        """
        for key, value in list(self.cache.items()):
            probe = getattr(value, '__health__', None)
            if probe is not None:
                yield str(key), probe

    def _scope_named(self, name: str) -> ScopeState:
        state: ScopeState | None = self
        while state is not None:
            if state.name == name:
                return state
            state = state.parent
        raise UnresolvedDependencyError(f'scope {name!r} is not active')

    def _root_for(self, typ: type) -> Any:
        state: ScopeState | None = self
        while state is not None:
            if state.root is not None and isinstance(state.root, typ):
                return state.root
            state = state.parent
        return _MISSING

    async def get(self, key_type: type, qualifier: str | None = None) -> Any:
        """Resolve a value for ``key_type`` within this scope's lineage."""
        key = Key(key_type, qualifier)
        binding = self.container.bindings.get(key)
        if binding is None:
            root = self._root_for(key_type)
            if root is not _MISSING:
                return root
            raise UnresolvedDependencyError(f'no binding or scope root for {key}')
        owner = self._scope_named(binding.scope)
        if key in owner.cache:
            return owner.cache[key]
        value = await self._build(binding, owner)
        owner.cache[key] = value
        return value

    async def _build(self, binding: Binding, owner: ScopeState) -> Any:
        kwargs: dict[str, Any] = {}
        for dep in self.container.deps[binding.key]:
            if dep.type is None:
                if dep.has_default:
                    continue
                raise UnresolvedDependencyError(
                    f'{binding.key}: parameter {dep.name!r} has no resolvable type',
                )
            dep_key = Key(dep.type, dep.qualifier)
            if dep_key in self.container.bindings:
                kwargs[dep.name] = await self.get(dep.type, dep.qualifier)
                continue
            root = self._root_for(dep.type)
            if root is not _MISSING:
                kwargs[dep.name] = root
            elif not dep.has_default:
                raise UnresolvedDependencyError(
                    f'{binding.key}: cannot resolve {dep.name}: {dep.type}',
                )
        result = binding.recipe(**kwargs)
        if hasattr(result, '__aenter__'):
            return await owner.stack.enter_async_context(result)
        if hasattr(result, '__enter__'):
            return owner.stack.enter_context(result)
        if inspect.isawaitable(result):
            return await result
        return result

health_probes

health_probes() -> Iterator[tuple[str, Callable[[], Any]]]

Yield (label, probe) for every resolved value carrying a __health__.

A value's __health__ is its health-check callable (sync or async, returning a truthy "ok"). Only values already resolved in this scope are probed — for an app scope that is everything eagerly built at startup (see :meth:Container.open_app_scope). Exposed here so callers (e.g. the FastAPI health endpoint) ask the scope for its probes rather than reaching into the resolution cache.

Source code in src/gazebo/di/container.py
def health_probes(self) -> Iterator[tuple[str, Callable[[], Any]]]:
    """Yield ``(label, probe)`` for every resolved value carrying a ``__health__``.

    A value's ``__health__`` is its health-check callable (sync or async, returning
    a truthy "ok"). Only values **already resolved** in this scope are probed — for
    an app scope that is everything eagerly built at startup (see
    :meth:`Container.open_app_scope`). Exposed here so callers (e.g. the FastAPI
    health endpoint) ask the scope for its probes rather than reaching into the
    resolution cache.
    """
    for key, value in list(self.cache.items()):
        probe = getattr(value, '__health__', None)
        if probe is not None:
            yield str(key), probe

get async

get(key_type: type, qualifier: str | None = None) -> Any

Resolve a value for key_type within this scope's lineage.

Source code in src/gazebo/di/container.py
async def get(self, key_type: type, qualifier: str | None = None) -> Any:
    """Resolve a value for ``key_type`` within this scope's lineage."""
    key = Key(key_type, qualifier)
    binding = self.container.bindings.get(key)
    if binding is None:
        root = self._root_for(key_type)
        if root is not _MISSING:
            return root
        raise UnresolvedDependencyError(f'no binding or scope root for {key}')
    owner = self._scope_named(binding.scope)
    if key in owner.cache:
        return owner.cache[key]
    value = await self._build(binding, owner)
    owner.cache[key] = value
    return value

Binding dataclass

A bound recipe: what builds a key, and in which scope.

Source code in src/gazebo/di/providers.py
@dataclass
class Binding:
    """A bound recipe: what builds a key, and in which scope."""

    key: Key
    scope: str
    recipe: Callable[..., Any]

HasProvide

Bases: Protocol

A type that colocates its own recipe as a __provide__ classmethod.

Source code in src/gazebo/di/providers.py
@runtime_checkable
class HasProvide(Protocol):
    """A type that colocates its own recipe as a ``__provide__`` classmethod."""

    @classmethod
    def __provide__(cls, *args: Any, **kwargs: Any) -> Any: ...

Key dataclass

A registry key: a type plus an optional qualifier.

Source code in src/gazebo/di/providers.py
@dataclass(frozen=True, slots=True)
class Key:
    """A registry key: a type plus an optional qualifier."""

    type: type
    qualifier: str | None = None

    def __str__(self) -> str:
        name = getattr(self.type, '__name__', repr(self.type))
        return f'{name}#{self.qualifier}' if self.qualifier else name

Overrides dataclass

A typed layer of replacements for bound recipes/values.

Mechanically a partial Providers layer: it replaces a binding's recipe (or supplies a constant instance), inheriting the binding's scope.

Source code in src/gazebo/di/providers.py
@dataclass
class Overrides:
    """A typed layer of replacements for bound recipes/values.

    Mechanically a partial ``Providers`` layer: it replaces a binding's recipe (or
    supplies a constant instance), inheriting the binding's scope.
    """

    _values: dict[Key, Any] = field(default_factory=dict)

    def set[T](self, key: type[T], value: T | Recipe[T], *, qualifier: str | None = None) -> Self:
        self._values[Key(key, qualifier)] = value
        return self

    @property
    def values(self) -> dict[Key, Any]:
        return dict(self._values)

Providers

The central registry binding each type to a scope (and its recipe).

Source code in src/gazebo/di/providers.py
class Providers:
    """The central registry binding each type to a scope (and its recipe)."""

    def __init__(self) -> None:
        self._bindings: dict[Key, Binding] = {}

    @overload
    def bind[T: HasProvide](
        self,
        key: type[T],
        *,
        scope: str,
        qualifier: str | None = None,
    ) -> Self: ...
    @overload
    def bind[T](
        self,
        key: type[T],
        recipe: Recipe[T],
        *,
        scope: str,
        qualifier: str | None = None,
    ) -> Self: ...

    def bind(
        self,
        key: type,
        recipe: Recipe[Any] | None = None,
        *,
        scope: str,
        qualifier: str | None = None,
    ) -> Self:
        if recipe is None:
            recipe = getattr(key, '__provide__', None)
            if recipe is None:
                raise TypeError(
                    f'{key.__name__} has no __provide__; supply a recipe to bind it',
                )
        k = Key(key, qualifier)
        self._bindings[k] = Binding(k, scope, normalize_recipe(recipe))
        return self

    @overload
    def app[T: HasProvide](self, key: type[T], *, qualifier: str | None = None) -> Self: ...
    @overload
    def app[T](
        self,
        key: type[T],
        recipe: Recipe[T],
        *,
        qualifier: str | None = None,
    ) -> Self: ...

    def app(
        self,
        key: type,
        recipe: Recipe[Any] | None = None,
        *,
        qualifier: str | None = None,
    ) -> Self:
        return self.bind(key, recipe, scope='app', qualifier=qualifier)  # type: ignore[arg-type]

    @overload
    def request[T: HasProvide](self, key: type[T], *, qualifier: str | None = None) -> Self: ...
    @overload
    def request[T](
        self,
        key: type[T],
        recipe: Recipe[T],
        *,
        qualifier: str | None = None,
    ) -> Self: ...

    def request(
        self,
        key: type,
        recipe: Recipe[Any] | None = None,
        *,
        qualifier: str | None = None,
    ) -> Self:
        return self.bind(key, recipe, scope='request', qualifier=qualifier)  # type: ignore[arg-type]

    @property
    def bindings(self) -> dict[Key, Binding]:
        return dict(self._bindings)

    def keys(self) -> set[Key]:
        return set(self._bindings)

Qualify dataclass

Annotation marker to disambiguate duplicate types.

def h(db: Annotated[Database, Qualify('replica')]): ...

Source code in src/gazebo/di/providers.py
@dataclass(frozen=True, slots=True)
class Qualify:
    """Annotation marker to disambiguate duplicate types.

    >>> def h(db: Annotated[Database, Qualify('replica')]): ...
    """

    qualifier: str

parse_annotation

parse_annotation(
    ann: Any,
) -> tuple[type | None, str | None, tuple[Any, ...]]

Split a type annotation into (base type, Qualify qualifier, metadata).

For Annotated[T, ...] returns T (when it is a class), the qualifier from any :class:Qualify marker, and the remaining Annotated metadata. For a plain annotation the metadata tuple is empty. A non-class base resolves to None so callers can treat it as unresolvable.

Source code in src/gazebo/di/providers.py
def parse_annotation(ann: Any) -> tuple[type | None, str | None, tuple[Any, ...]]:
    """Split a type annotation into ``(base type, Qualify qualifier, metadata)``.

    For ``Annotated[T, ...]`` returns ``T`` (when it is a class), the qualifier from
    any :class:`Qualify` marker, and the remaining ``Annotated`` metadata. For a plain
    annotation the metadata tuple is empty. A non-class base resolves to ``None`` so
    callers can treat it as unresolvable.
    """
    if get_origin(ann) is Annotated:
        args = get_args(ann)
        base = args[0]
        meta = args[1:]
        qualifier = next((m.qualifier for m in meta if isinstance(m, Qualify)), None)
        return (base if isinstance(base, type) else None), qualifier, meta
    return (ann if isinstance(ann, type) else None), None, ()

resolve_annotation

resolve_annotation(
    annotation: Any, globalns: dict[str, Any]
) -> tuple[Any, bool]

Leniently resolve one (possibly stringized) annotation in isolation.

Under from __future__ import annotations every annotation reaches us as a string. Unlike get_type_hints — which resolves a function's hints as a single unit and raises if any name is undefined — this evaluates one annotation on its own and tolerates failure, mirroring how FastAPI resolves parameter types. So a single unresolvable sibling (e.g. a name imported only under if TYPE_CHECKING:) no longer hides the injectable parameters next to it. Returns (value, resolved); on failure the original string is returned with resolved=False.

Source code in src/gazebo/di/providers.py
def resolve_annotation(annotation: Any, globalns: dict[str, Any]) -> tuple[Any, bool]:
    """Leniently resolve one (possibly stringized) annotation in isolation.

    Under ``from __future__ import annotations`` every annotation reaches us as a
    string. Unlike ``get_type_hints`` — which resolves a function's hints as a single
    unit and raises if *any* name is undefined — this evaluates one annotation on its
    own and tolerates failure, mirroring how FastAPI resolves parameter types. So a
    single unresolvable sibling (e.g. a name imported only under ``if TYPE_CHECKING:``)
    no longer hides the injectable parameters next to it. Returns ``(value, resolved)``;
    on failure the original string is returned with ``resolved=False``.
    """
    if not isinstance(annotation, str):
        return annotation, True
    try:
        return eval(annotation, globalns), True  # noqa: S307
    except Exception:  # noqa: BLE001
        return annotation, False

FastAPI integration

gazebo.ext.fastapi

FastAPI glue.

Turns a central :class:~gazebo.di.Providers registry into a working app: GazeboApp enters the app scope in its lifespan, opens a request scope per request (publishing the link RequestContext), and resolves bound types injected into routes. Routes opt into bare-type injection by being declared on a GazeboRouter (or directly on the app): any parameter whose type carries a __provide__ recipe, or is marked Annotated[T, Inject], is resolved from the per-request DI scope.

This is the only part of gazebo that imports fastapi (the gazebo[fastapi] extra). It is organized as a package — one module per concern (injection, OGC param adapters, CORS, response helpers, routers, app wiring) — but the public surface is flat: import everything straight from gazebo.ext.fastapi.

BBoxParam module-attribute

BBoxParam = Depends(_bbox_dep)

Parses the OGC bbox query value into a :class:~gazebo.params.BBox.

DatetimeParam module-attribute

DatetimeParam = Depends(_datetime_dep)

Parses the OGC datetime query value into a :class:~gazebo.params.DatetimeInterval.

BBoxQuery module-attribute

BBoxQuery = Annotated[
    BBox | None,
    BeforeValidator(
        _parse_bbox_field, json_schema_input_type=str
    ),
    Field(
        default=None,
        description=BBOX_DESCRIPTION,
        examples=BBOX_EXAMPLES,
    ),
]

A folded bbox query field: parses to a :class:BBox (or None), fully documented.

Put it on a consumer's own pydantic query model (used as Annotated[MyQuery, Query()]); a malformed value becomes a 400 application/problem+json under a :class:GazeboApp.

DatetimeQuery module-attribute

DatetimeQuery = Annotated[
    DatetimeInterval | None,
    BeforeValidator(
        _parse_datetime_field, json_schema_input_type=str
    ),
    Field(
        default=None,
        description=DATETIME_DESCRIPTION,
        examples=DATETIME_EXAMPLES,
    ),
]

A folded datetime query field: parses to a :class:DatetimeInterval (or None).

Cors

Cors = bool | str | Sequence[str] | CorsConfig | None

How to configure CORS: None/False off, True permissive, a list of allowed origins, or a full :class:CorsConfig.

Overrides dataclass

A typed layer of replacements for bound recipes/values.

Mechanically a partial Providers layer: it replaces a binding's recipe (or supplies a constant instance), inheriting the binding's scope.

Source code in src/gazebo/di/providers.py
@dataclass
class Overrides:
    """A typed layer of replacements for bound recipes/values.

    Mechanically a partial ``Providers`` layer: it replaces a binding's recipe (or
    supplies a constant instance), inheriting the binding's scope.
    """

    _values: dict[Key, Any] = field(default_factory=dict)

    def set[T](self, key: type[T], value: T | Recipe[T], *, qualifier: str | None = None) -> Self:
        self._values[Key(key, qualifier)] = value
        return self

    @property
    def values(self) -> dict[Key, Any]:
        return dict(self._values)

Providers

The central registry binding each type to a scope (and its recipe).

Source code in src/gazebo/di/providers.py
class Providers:
    """The central registry binding each type to a scope (and its recipe)."""

    def __init__(self) -> None:
        self._bindings: dict[Key, Binding] = {}

    @overload
    def bind[T: HasProvide](
        self,
        key: type[T],
        *,
        scope: str,
        qualifier: str | None = None,
    ) -> Self: ...
    @overload
    def bind[T](
        self,
        key: type[T],
        recipe: Recipe[T],
        *,
        scope: str,
        qualifier: str | None = None,
    ) -> Self: ...

    def bind(
        self,
        key: type,
        recipe: Recipe[Any] | None = None,
        *,
        scope: str,
        qualifier: str | None = None,
    ) -> Self:
        if recipe is None:
            recipe = getattr(key, '__provide__', None)
            if recipe is None:
                raise TypeError(
                    f'{key.__name__} has no __provide__; supply a recipe to bind it',
                )
        k = Key(key, qualifier)
        self._bindings[k] = Binding(k, scope, normalize_recipe(recipe))
        return self

    @overload
    def app[T: HasProvide](self, key: type[T], *, qualifier: str | None = None) -> Self: ...
    @overload
    def app[T](
        self,
        key: type[T],
        recipe: Recipe[T],
        *,
        qualifier: str | None = None,
    ) -> Self: ...

    def app(
        self,
        key: type,
        recipe: Recipe[Any] | None = None,
        *,
        qualifier: str | None = None,
    ) -> Self:
        return self.bind(key, recipe, scope='app', qualifier=qualifier)  # type: ignore[arg-type]

    @overload
    def request[T: HasProvide](self, key: type[T], *, qualifier: str | None = None) -> Self: ...
    @overload
    def request[T](
        self,
        key: type[T],
        recipe: Recipe[T],
        *,
        qualifier: str | None = None,
    ) -> Self: ...

    def request(
        self,
        key: type,
        recipe: Recipe[Any] | None = None,
        *,
        qualifier: str | None = None,
    ) -> Self:
        return self.bind(key, recipe, scope='request', qualifier=qualifier)  # type: ignore[arg-type]

    @property
    def bindings(self) -> dict[Key, Binding]:
        return dict(self._bindings)

    def keys(self) -> set[Key]:
        return set(self._bindings)

GazeboApp

Bases: FastAPI

A FastAPI app wired from a :class:Providers registry (thin over :func:upgrade).

Source code in src/gazebo/ext/fastapi/app.py
class GazeboApp(FastAPI):
    """A FastAPI app wired from a :class:`Providers` registry (thin over :func:`upgrade`)."""

    def __init__(
        self,
        providers: Providers | None = None,
        *,
        overrides: Overrides | None = None,
        trust: TrustPolicy = trust_none,
        cors: Cors = None,
        health_path: str | None = '/health',
        query_problem: ProblemType | None = None,
        body_problem: ProblemType | None = None,
        **fastapi_kwargs: Any,
    ) -> None:
        super().__init__(**fastapi_kwargs)
        upgrade(
            self,
            providers,
            overrides=overrides,
            trust=trust,
            cors=cors,
            health_path=health_path,
            query_problem=query_problem,
            body_problem=body_problem,
        )

    @property
    def container(self) -> Container:
        return _runtime_of(self).container

    @property
    def app_state(self) -> ScopeState:
        state = _runtime_of(self).app_state
        if state is None:
            raise RuntimeError('app scope is not open (is the app started?)')
        return state

RequestContextAdapter

Adapts a FastAPI Request to the :class:RequestContext protocol.

Source code in src/gazebo/ext/fastapi/context.py
class RequestContextAdapter:
    """Adapts a FastAPI ``Request`` to the :class:`RequestContext` protocol."""

    def __init__(self, request: Request) -> None:
        self._request = request

    @property
    def base_url(self) -> str:
        return str(self._request.base_url)

    @property
    def url(self) -> str:
        return str(self._request.url)

    @property
    def query_params(self) -> Mapping[str, str]:
        return dict(self._request.query_params)

    @property
    def headers(self) -> Mapping[str, str]:
        # Starlette's Headers is already a case-insensitive Mapping[str, str].
        return self._request.headers

    def url_for(self, name: str, /, **path: object) -> str:
        return str(self._request.url_for(name, **path))

    def url_for_template(
        self,
        name: str,
        path: Mapping[str, object],
        template: Sequence[str],
        /,
    ) -> str:
        # Resolve through the real router (preserving root_path and proxy
        # scheme/host) using unreserved-ASCII sentinels for the unbound vars, then
        # rewrite each sentinel back to an RFC 6570 {var}.
        subs = {v: f'__gztpl_{v}__' for v in template}
        url = str(self._request.url_for(name, **path, **subs))
        for v, token in subs.items():
            url = url.replace(token, f'{{{v}}}')
        return url

CorsConfig dataclass

A CORS policy for a gazebo app, mirroring Starlette's CORSMiddleware.

The permissive defaults (allow_origins=['*'] with credentials off) are what cors=True selects — fine for local development, but tighten allow_origins for anything browser-facing in production. allow_origins=['*'] with allow_credentials=True is rejected by browsers, so credentials default off.

Source code in src/gazebo/ext/fastapi/cors.py
@dataclass(frozen=True, slots=True)
class CorsConfig:
    """A CORS policy for a gazebo app, mirroring Starlette's ``CORSMiddleware``.

    The permissive defaults (``allow_origins=['*']`` with credentials off) are what
    ``cors=True`` selects — fine for local development, but tighten ``allow_origins``
    for anything browser-facing in production. ``allow_origins=['*']`` with
    ``allow_credentials=True`` is rejected by browsers, so credentials default off.
    """

    allow_origins: Sequence[str] = ('*',)
    allow_methods: Sequence[str] = ('*',)
    allow_headers: Sequence[str] = ('*',)
    allow_credentials: bool = False
    allow_origin_regex: str | None = None
    expose_headers: Sequence[str] = field(default_factory=tuple)
    max_age: int = 600

    @classmethod
    def resolve(cls, cors: Cors) -> CorsConfig | None:
        """Normalize a loose ``cors=`` argument into a config (``None`` means off).

        ``None``/``False`` → off, ``True`` → permissive defaults, a string or list →
        an allow-list of origins, a :class:`CorsConfig` → itself.
        """
        if cors is None or cors is False:
            return None
        if cors is True:
            return cls()
        if isinstance(cors, CorsConfig):
            return cors
        origins = (cors,) if isinstance(cors, str) else tuple(cors)
        return cls(allow_origins=origins)

    def apply(self, app: FastAPI) -> None:
        """Install this policy on ``app`` as a ``CORSMiddleware`` layer.

        The field names mirror ``CORSMiddleware``'s parameters one-for-one, so the
        config *is* the keyword set — ``asdict`` keeps the two in sync with no
        hand-maintained mapping. Call it last in ``upgrade`` so CORS ends up the
        outermost middleware (headers ride on every response, including problems).
        """
        app.add_middleware(CORSMiddleware, **asdict(self))

resolve classmethod

resolve(cors: Cors) -> CorsConfig | None

Normalize a loose cors= argument into a config (None means off).

None/False → off, True → permissive defaults, a string or list → an allow-list of origins, a :class:CorsConfig → itself.

Source code in src/gazebo/ext/fastapi/cors.py
@classmethod
def resolve(cls, cors: Cors) -> CorsConfig | None:
    """Normalize a loose ``cors=`` argument into a config (``None`` means off).

    ``None``/``False`` → off, ``True`` → permissive defaults, a string or list →
    an allow-list of origins, a :class:`CorsConfig` → itself.
    """
    if cors is None or cors is False:
        return None
    if cors is True:
        return cls()
    if isinstance(cors, CorsConfig):
        return cors
    origins = (cors,) if isinstance(cors, str) else tuple(cors)
    return cls(allow_origins=origins)

apply

apply(app: FastAPI) -> None

Install this policy on app as a CORSMiddleware layer.

The field names mirror CORSMiddleware's parameters one-for-one, so the config is the keyword set — asdict keeps the two in sync with no hand-maintained mapping. Call it last in upgrade so CORS ends up the outermost middleware (headers ride on every response, including problems).

Source code in src/gazebo/ext/fastapi/cors.py
def apply(self, app: FastAPI) -> None:
    """Install this policy on ``app`` as a ``CORSMiddleware`` layer.

    The field names mirror ``CORSMiddleware``'s parameters one-for-one, so the
    config *is* the keyword set — ``asdict`` keeps the two in sync with no
    hand-maintained mapping. Call it last in ``upgrade`` so CORS ends up the
    outermost middleware (headers ride on every response, including problems).
    """
    app.add_middleware(CORSMiddleware, **asdict(self))

GazeboRouter

Bases: APIRouter

An APIRouter that rewrites routes for bare-type injection at decoration.

Source code in src/gazebo/ext/fastapi/routers.py
class GazeboRouter(APIRouter):
    """An ``APIRouter`` that rewrites routes for bare-type injection at decoration."""

    def add_api_route(self, path: str, endpoint: Callable[..., Any], **kwargs: Any) -> None:
        kwargs = fold_negotiated_responses(endpoint, kwargs)
        return super().add_api_route(path, inject_signature(endpoint), **kwargs)

LinkedRouter

Bases: GazeboRouter

A :class:GazeboRouter that auto-generates a hierarchical landing page.

Mounts a landing endpoint at its root; include_router of another LinkedRouter (that declares a rel) adds a link to that child's landing page, so the hierarchy falls out of router nesting.

Source code in src/gazebo/ext/fastapi/routers.py
class LinkedRouter(GazeboRouter):
    """A :class:`GazeboRouter` that auto-generates a hierarchical landing page.

    Mounts a landing endpoint at its root; ``include_router`` of another
    ``LinkedRouter`` (that declares a ``rel``) adds a link to that child's landing
    page, so the hierarchy falls out of router nesting.
    """

    def __init__(
        self,
        *args: Any,
        rel: str | None = None,
        title: str = '',
        description: str = '',
        landing_name: str = 'landing',
        **kwargs: Any,
    ) -> None:
        super().__init__(*args, **kwargs)
        self.rel = rel
        self.title = title
        self.description = description
        self.landing_name = landing_name
        self._link_specs: list[tuple[str, str, str | None, str]] = []
        self._mount_landing()

    def _mount_landing(self) -> None:
        router = self

        @self.get('/', name=self.landing_name, response_model=LandingPage)
        async def landing(request: Request) -> LandingPage:
            return router._landing_page(request)

    def _landing_page(self, request: Request) -> LandingPage:
        """Build this router's landing page. Subclasses extend the link set here."""
        links = [Link.self_link(), Link.root_link()]
        for rel, name, title, media in self._link_specs:
            links.append(Link.to_route(name, rel=rel, title=title, type=media))
        return LandingPage(title=self.title, description=self.description, links=links)

    def add_link(
        self,
        rel: str,
        route_name: str,
        *,
        title: str | None = None,
        type: str = MediaType.JSON,
    ) -> None:
        self._link_specs.append((rel, route_name, title, type))

    def include_router(self, router: Any, *, prefix: str = '', **kwargs: Any) -> None:
        super().include_router(router, prefix=prefix, **kwargs)
        if isinstance(router, LinkedRouter) and router.rel:
            self.add_link(router.rel, router.landing_name, title=router.title or None)

RootRouter

Bases: LinkedRouter

The service's root landing page: hierarchy plus service-level wiring.

A :class:LinkedRouter for the top of the tree. Beyond the hierarchical landing page, its landing page additionally:

  • emits service-desc/service-doc links to the app's OpenAPI document and its docs UI (each omitted when the app has that URL disabled),
  • falls back its title/description to the app's when not set explicitly (so the service name lives in one place — on the app), and
  • links to a /conformance declaration it auto-mounts. That declaration's baseline (core/landing-page/json, plus oas30 when the app exposes OpenAPI) is derived from the running app, then merged with any conformance classes you contribute — so the declaration stays honest instead of drifting from what's actually wired.

Contribute feature-level classes via conformance= (a :class:Conformance or a list of class URIs), e.g. conformance=[*filter_conformance_classes()].

Source code in src/gazebo/ext/fastapi/routers.py
class RootRouter(LinkedRouter):
    """The service's root landing page: hierarchy plus service-level wiring.

    A :class:`LinkedRouter` for the top of the tree. Beyond the hierarchical landing
    page, its landing page additionally:

    - emits ``service-desc``/``service-doc`` links to the app's OpenAPI document and
      its docs UI (each omitted when the app has that URL disabled),
    - falls back its ``title``/``description`` to the app's when not set explicitly
      (so the service name lives in one place — on the app), and
    - links to a ``/conformance`` declaration it auto-mounts. That declaration's
      baseline (``core``/``landing-page``/``json``, plus ``oas30`` when the app exposes
      OpenAPI) is derived from the *running app*, then merged with any conformance
      classes you contribute — so the declaration stays honest instead of drifting
      from what's actually wired.

    Contribute feature-level classes via ``conformance=`` (a :class:`Conformance` or a
    list of class URIs), e.g. ``conformance=[*filter_conformance_classes()]``.
    """

    def __init__(
        self,
        *args: Any,
        conformance: Conformance | Iterable[str] | None = None,
        conformance_name: str = 'conformance',
        conformance_path: str | None = '/conformance',
        **kwargs: Any,
    ) -> None:
        self._extra_conformance = (
            conformance
            if isinstance(conformance, Conformance)
            else Conformance(*(conformance or ()))
        )
        self._conformance_name = conformance_name
        self._conformance_path = conformance_path
        super().__init__(*args, **kwargs)
        if conformance_path is not None:
            self._mount_conformance(conformance_path)

    def _mount_conformance(self, path: str) -> None:
        router = self

        @self.get(path, name=self._conformance_name, response_model=ConformanceDeclaration)
        async def conformance(request: Request) -> ConformanceDeclaration:
            return router._conformance_declaration(request)

    def _conformance_declaration(self, request: Request) -> ConformanceDeclaration:
        # Baseline derived from the running app: a landing page + JSON are always true
        # here; OAS30 holds only when the app actually exposes an OpenAPI document.
        conf = Conformance(Conformance.CORE, Conformance.LANDING_PAGE, Conformance.JSON)
        if request.app.openapi_url:
            conf.add(Conformance.OAS30)
        conf.add(*self._extra_conformance.uris)
        return conf.declaration()

    def _landing_page(self, request: Request) -> LandingPage:
        page = super()._landing_page(request)
        if not self.title:
            # Falls back to the app's title — which is FastAPI's default ('FastAPI')
            # when the app sets none either, so the service name belongs on the app.
            page.title = request.app.title
        if not self.description and request.app.description:
            page.description = request.app.description
        page.links.extend(self._service_links(request))
        return page

    def _service_links(self, request: Request) -> list[Link]:
        base = str(request.base_url).rstrip('/')
        openapi_url = request.app.openapi_url
        links: list[Link] = []
        if self._conformance_path is not None:
            links.append(
                Link.to_route(
                    self._conformance_name,
                    rel=Rel.CONFORMANCE,
                    type=MediaType.JSON,
                    title='Conformance',
                ),
            )
        if openapi_url:
            links.append(
                Link(
                    href=base + openapi_url,
                    rel=Rel.SERVICE_DESC,
                    type=MediaType.OPENAPI,
                    title='API definition',
                ),
            )
        # The docs UI fetches the OpenAPI document, so FastAPI only mounts it when
        # openapi_url is set too — don't advertise a service-doc that 404s without it.
        if openapi_url and request.app.docs_url:
            links.append(
                Link(
                    href=base + request.app.docs_url,
                    rel=Rel.SERVICE_DOC,
                    type=MediaType.HTML,
                    title='API documentation',
                ),
            )
        return links

FormatEnum

Bases: StrEnum

Base StrEnum for a folded ?f= (format) query field's closed key set.

Subclass it, spelling each supported output format as a member of key, media_type: the value is the ?f= key and media_type is what that key serves. Then fold the subclass into a pydantic query model as a real field type (no type: ignore needed — it is an ordinary class)::

class BedFormat(FormatEnum):
    json = 'json', 'application/json'
    html = 'html', 'text/html'

class BedQuery(BaseModel):
    f: BedFormat = BedFormat.json

Pydantic validates membership by the key (member value): an unknown ?f= is a ValidationError that a :class:~gazebo.ext.fastapi.GazeboApp renders as a 400 application/problem+json citing the parameter. FastAPI renders the field as an enum of keys, and the base injects an OpenAPI description that names the subclass's actual ?f= keys (via :func:f_description) so it self-documents. Give the field a plain default so an absent ?f= resolves to it.

Because each member carries its media type, :attr:representation turns a chosen member into a :class:Representation with no external mapping — drive rendering and :func:alternate_links straight off the member. For Accept-aware negotiation (?f=Accept → default, and its 406), make the field an optional member (f: MyFormat | None = None) and add a one-line negotiate(MyFormat.representations(), f=query.f) call in the handler — :func:negotiate reads the request's Accept from the ambient context, so no header wrangling is needed.

Source code in src/gazebo/negotiation.py
class FormatEnum(StrEnum):
    """Base ``StrEnum`` for a folded ``?f=`` (format) query field's closed key set.

    Subclass it, spelling each supported output format as a member of ``key, media_type``:
    the *value* is the ``?f=`` key and ``media_type`` is what that key serves. Then fold
    the subclass into a pydantic query model as a real field type (no ``type: ignore``
    needed — it is an ordinary class)::

        class BedFormat(FormatEnum):
            json = 'json', 'application/json'
            html = 'html', 'text/html'

        class BedQuery(BaseModel):
            f: BedFormat = BedFormat.json

    Pydantic validates membership by the *key* (member value): an unknown ``?f=`` is a
    ``ValidationError`` that a :class:`~gazebo.ext.fastapi.GazeboApp` renders as a `400`
    ``application/problem+json`` citing the parameter. FastAPI renders the field as an
    ``enum`` of keys, and the base injects an OpenAPI description that names the
    subclass's actual ``?f=`` keys (via :func:`f_description`) so it self-documents. Give
    the field a plain default so an absent ``?f=`` resolves to it.

    Because each member carries its media type, :attr:`representation` turns a chosen
    member into a :class:`Representation` with no external mapping — drive rendering and
    :func:`alternate_links` straight off the member. For ``Accept``-aware negotiation
    (``?f=`` → ``Accept`` → default, and its `406`), make the field an **optional**
    member (``f: MyFormat | None = None``) and add a one-line
    ``negotiate(MyFormat.representations(), f=query.f)`` call in the handler —
    :func:`negotiate` reads the request's ``Accept`` from the ambient context, so no
    header wrangling is needed.
    """

    media_type: str

    def __new__(cls, key: str, media_type: str) -> FormatEnum:
        obj = str.__new__(cls, key)
        obj._value_ = key
        obj.media_type = media_type
        return obj

    @property
    def representation(self) -> Representation:
        """The :class:`Representation` (``?f=`` key + media type) this member serves."""
        return Representation(self.value, self.media_type)

    @classmethod
    def representations(cls) -> list[Representation]:
        """Every member's :class:`Representation`, in definition (server-preferred) order.

        The ``available`` list for :func:`negotiate` straight off the enum, so a folded
        field negotiates with ``negotiate(MyFormat.representations(), f=query.f)``.
        """
        return [member.representation for member in cls]

    @classmethod
    def openapi_responses(cls, **kwargs: Any) -> dict[int | str, dict[str, Any]]:
        """:func:`openapi_responses` over this enum's :meth:`representations`.

        So a folded field documents every negotiated media type straight off the enum;
        ``kwargs`` are forwarded to :func:`openapi_responses` (``status``, ``schemas``,
        ``default_schema``).
        """
        return openapi_responses(cls.representations(), **kwargs)

    @classmethod
    def __get_pydantic_json_schema__(
        cls,
        core_schema: CoreSchema,
        handler: GetJsonSchemaHandler,
    ) -> JsonSchemaValue:
        # Force the shared OGC description onto the enum's schema so a folded field
        # self-documents in OpenAPI (FastAPI reads the parameter description from the
        # enum's own schema, not only from a consumer Field(...)). We overwrite rather than
        # setdefault: pydantic pre-fills ``description`` from the subclass docstring, but
        # the API parameter wants the OGC one. The subclass knows its own members, so name
        # the actual ``?f=`` keys rather than a hardcoded example.
        schema = handler(core_schema)
        schema['description'] = f_description(member.value for member in cls)
        return schema

representation property

representation: Representation

The :class:Representation (?f= key + media type) this member serves.

representations classmethod

representations() -> list[Representation]

Every member's :class:Representation, in definition (server-preferred) order.

The available list for :func:negotiate straight off the enum, so a folded field negotiates with negotiate(MyFormat.representations(), f=query.f).

Source code in src/gazebo/negotiation.py
@classmethod
def representations(cls) -> list[Representation]:
    """Every member's :class:`Representation`, in definition (server-preferred) order.

    The ``available`` list for :func:`negotiate` straight off the enum, so a folded
    field negotiates with ``negotiate(MyFormat.representations(), f=query.f)``.
    """
    return [member.representation for member in cls]

openapi_responses classmethod

openapi_responses(
    **kwargs: Any,
) -> dict[int | str, dict[str, Any]]

:func:openapi_responses over this enum's :meth:representations.

So a folded field documents every negotiated media type straight off the enum; kwargs are forwarded to :func:openapi_responses (status, schemas, default_schema).

Source code in src/gazebo/negotiation.py
@classmethod
def openapi_responses(cls, **kwargs: Any) -> dict[int | str, dict[str, Any]]:
    """:func:`openapi_responses` over this enum's :meth:`representations`.

    So a folded field documents every negotiated media type straight off the enum;
    ``kwargs`` are forwarded to :func:`openapi_responses` (``status``, ``schemas``,
    ``default_schema``).
    """
    return openapi_responses(cls.representations(), **kwargs)

CrsEnum

Bases: StrEnum

Base StrEnum for a folded crs/bbox-crs query field's closed CRS set.

Subclass it, spelling each allowed CRS as a member whose value is the CRS URI, then fold the subclass into a pydantic query model as a real field type (no type: ignore needed — it is an ordinary class)::

class BedCrs(CrsEnum):
    CRS84 = CRS84
    WEB_MERCATOR = 'http://www.opengis.net/def/crs/EPSG/0/3857'

class BedQuery(BaseModel):
    crs: BedCrs = BedCrs.CRS84

Because members are real strings (the URIs), the field value is usable downstream as a URI and serializes as one. Pydantic validates membership natively: an unknown value is a ValidationError that a :class:~gazebo.ext.fastapi.GazeboApp renders as a 400 application/problem+json citing the parameter. FastAPI renders the field as an enum query param, and the base carries :data:CRS_DESCRIPTION so it self-documents in the generated OpenAPI. Give the field a default (typically CRS84) so an absent value resolves to it.

Source code in src/gazebo/params.py
class CrsEnum(StrEnum):
    """Base ``StrEnum`` for a folded ``crs``/``bbox-crs`` query field's closed CRS set.

    Subclass it, spelling each allowed CRS as a member whose value is the CRS URI, then
    fold the subclass into a pydantic query model as a real field type (no ``type: ignore``
    needed — it is an ordinary class)::

        class BedCrs(CrsEnum):
            CRS84 = CRS84
            WEB_MERCATOR = 'http://www.opengis.net/def/crs/EPSG/0/3857'

        class BedQuery(BaseModel):
            crs: BedCrs = BedCrs.CRS84

    Because members are real strings (the URIs), the field value is usable downstream as a
    URI and serializes as one. Pydantic validates membership natively: an unknown value is
    a ``ValidationError`` that a :class:`~gazebo.ext.fastapi.GazeboApp` renders as a `400`
    ``application/problem+json`` citing the parameter. FastAPI renders the field as an
    ``enum`` query param, and the base carries :data:`CRS_DESCRIPTION` so it self-documents
    in the generated OpenAPI. Give the field a default (typically ``CRS84``) so an absent
    value resolves to it.
    """

    @classmethod
    def __get_pydantic_json_schema__(
        cls,
        core_schema: CoreSchema,
        handler: GetJsonSchemaHandler,
    ) -> JsonSchemaValue:
        # Force the shared OGC description onto the enum's schema so a folded field
        # self-documents in OpenAPI with the *parameter's* meaning. FastAPI reads the
        # parameter description from the (possibly $ref'd) enum schema, so it must live on
        # the schema itself, not only on a Field(...) the consumer would otherwise add. We
        # overwrite rather than setdefault: pydantic pre-fills ``description`` from the
        # subclass docstring (developer-facing), but the API parameter wants CRS_DESCRIPTION.
        schema = handler(core_schema)
        schema['description'] = CRS_DESCRIPTION
        return schema

etag_for

etag_for(value: Any, *, weak: bool = True) -> str

Derive an ETag from value (a model, mapping, str, or bytes).

The value is reduced to canonical bytes — a pydantic model via model_dump_json(by_alias=True), anything else via sorted-key JSON — and hashed (SHA-256). The result is a quoted entity-tag, prefixed W/ when weak (the default).

Note

A model carrying deferred (callable-href) links only serializes inside an active request context; outside one, ETag such a model from its underlying data rather than the link-bearing envelope.

Source code in src/gazebo/caching.py
def etag_for(value: Any, *, weak: bool = True) -> str:
    """Derive an ``ETag`` from ``value`` (a model, mapping, str, or bytes).

    The value is reduced to canonical bytes — a pydantic model via
    ``model_dump_json(by_alias=True)``, anything else via sorted-key JSON — and hashed
    (SHA-256). The result is a quoted entity-tag, prefixed ``W/`` when ``weak`` (the
    default).

    Note:
        A model carrying deferred (callable-href) links only serializes inside an
        active request context; outside one, ETag such a model from its underlying
        data rather than the link-bearing envelope.
    """
    digest = hashlib.sha256(_canonical_bytes(value)).hexdigest()
    etag = f'"{digest}"'
    return f'W/{etag}' if weak else etag

forward_lifespans

forward_lifespans(
    *subapps: FastAPI,
) -> Callable[[FastAPI], Any]

A lifespan that runs each mounted sub-app's lifespan.

Use when mounting a GazeboApp under a root app, since a mounted sub-app's lifespan is not run automatically::

root = FastAPI(lifespan=forward_lifespans(sub))
root.mount('/api', sub)
Source code in src/gazebo/ext/fastapi/app.py
def forward_lifespans(*subapps: FastAPI) -> Callable[[FastAPI], Any]:
    """A lifespan that runs each mounted sub-app's lifespan.

    Use when mounting a ``GazeboApp`` under a root app, since a mounted sub-app's
    lifespan is not run automatically::

        root = FastAPI(lifespan=forward_lifespans(sub))
        root.mount('/api', sub)
    """

    @asynccontextmanager
    async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        async with AsyncExitStack() as stack:
            for sub in subapps:
                await stack.enter_async_context(sub.router.lifespan_context(sub))
            yield

    return lifespan

upgrade

upgrade(
    app: FastAPI,
    providers: Providers | None = None,
    *,
    overrides: Overrides | None = None,
    trust: TrustPolicy = trust_none,
    cors: Cors = None,
    health_path: str | None = '/health',
    query_problem: ProblemType | None = None,
    body_problem: ProblemType | None = None,
) -> FastAPI

Add gazebo's injection/context machinery to an existing FastAPI app.

Equivalent to constructing a :class:GazeboApp, but applied to an app you did not create (e.g. one built by a framework or with custom config). Wraps the app's lifespan (opening the app scope), installs the proxy-headers and request-scope middleware, registers the problem handlers, and rewrites @app.get routes for injection. Injectable routes still belong on a GazeboRouter (or @app.get on this app). Idempotent.

query_problem/body_problem give the framework's own validation/param errors a resolvable type (see :func:~gazebo.ext.fastapi.install_problem_handlers); both default to today's typeless about:blank problem.

Source code in src/gazebo/ext/fastapi/app.py
def upgrade(
    app: FastAPI,
    providers: Providers | None = None,
    *,
    overrides: Overrides | None = None,
    trust: TrustPolicy = trust_none,
    cors: Cors = None,
    health_path: str | None = '/health',
    query_problem: ProblemType | None = None,
    body_problem: ProblemType | None = None,
) -> FastAPI:
    """Add gazebo's injection/context machinery to an *existing* FastAPI app.

    Equivalent to constructing a :class:`GazeboApp`, but applied to an app you did
    not create (e.g. one built by a framework or with custom config). Wraps the
    app's lifespan (opening the app scope), installs the proxy-headers and
    request-scope middleware, registers the problem handlers, and rewrites
    ``@app.get`` routes for injection. Injectable routes still belong on a
    ``GazeboRouter`` (or ``@app.get`` on this app). Idempotent.

    ``query_problem``/``body_problem`` give the framework's own validation/param
    errors a resolvable ``type`` (see :func:`~gazebo.ext.fastapi.install_problem_handlers`);
    both default to today's typeless ``about:blank`` problem.
    """
    if getattr(app.state, _RUNTIME_ATTR, None) is not None:
        return app

    providers = providers or Providers()
    if Key(RequestContext) not in providers.bindings:  # type: ignore[type-abstract]
        # Layer the default binding into a *fresh* registry rather than mutating the
        # caller's `Providers` — `upgrade()` must not have side effects on registries
        # it did not create. Re-binding through the public API is safe: `bind` re-runs
        # `normalize_recipe`, which is idempotent.
        with_default = Providers()
        for binding in providers.bindings.values():
            with_default.bind(
                binding.key.type,
                binding.recipe,
                scope=binding.scope,
                qualifier=binding.key.qualifier,
            )
        with_default.request(RequestContext, _provide_request_context)  # type: ignore[type-abstract]
        providers = with_default
    container = Container(providers, overrides=overrides, roots={'request': Request})
    runtime = _Runtime(container)
    setattr(app.state, _RUNTIME_ATTR, runtime)

    original_add = app.router.add_api_route

    def add_api_route(path: str, endpoint: Callable[..., Any], **kwargs: Any) -> None:
        kwargs = fold_negotiated_responses(endpoint, kwargs)
        return original_add(path, inject_signature(endpoint), **kwargs)

    app.router.add_api_route = add_api_route  # type: ignore[method-assign]

    app.add_middleware(ProxyHeadersMiddleware, trust=trust)
    app.add_middleware(_RequestScopeMiddleware, runtime=runtime)
    # Added last so it is the outermost middleware: CORS then handles preflight
    # requests and attaches headers to every response, including problem responses.
    if (cors_config := CorsConfig.resolve(cors)) is not None:
        cors_config.apply(app)
    install_problem_handlers(app, query_problem=query_problem, body_problem=body_problem)

    previous_lifespan = app.router.lifespan_context

    @asynccontextmanager
    async def lifespan(a: FastAPI) -> AsyncIterator[None]:
        _validate_routes(a, container)
        _validate_unique_route_names(a)
        async with container.open_app_scope() as app_state:
            runtime.app_state = app_state
            setattr(a.state, _STATE_ATTR, app_state)
            try:
                async with previous_lifespan(a):
                    yield
            finally:
                runtime.app_state = None

    app.router.lifespan_context = lifespan  # type: ignore[assignment]

    if health_path is not None:
        _add_health(app, runtime, health_path)
    return app

FilterParam

FilterParam(
    queryables: Queryables,
    *,
    engine: FilterEngine | None = None,
    name: str = 'filter',
    lang_name: str = 'filter-lang',
    crs_name: str = 'filter-crs',
    crs_allowed: Sequence[str] = (CRS84,),
) -> Any

Build a Depends resolving the OGC filter parameter into a :class:Filter.

Drop it into a route as filter: Annotated[Filter | None, FilterParam(QUERYABLES)]. An absent filter resolves to None. filter-lang selects the encoding (else it is inferred); filter-crs is validated against crs_allowed. A parse/validation failure, an unknown language, an unsupported CRS, or a reference to a non-queryable property each become a 400 problem.

Source code in src/gazebo/ext/fastapi/filtering.py
def FilterParam(  # noqa: N802  (factory returning a Depends, named like one)
    queryables: Queryables,
    *,
    engine: FilterEngine | None = None,
    name: str = 'filter',
    lang_name: str = 'filter-lang',
    crs_name: str = 'filter-crs',
    crs_allowed: Sequence[str] = (CRS84,),
) -> Any:
    """Build a ``Depends`` resolving the OGC ``filter`` parameter into a :class:`Filter`.

    Drop it into a route as ``filter: Annotated[Filter | None, FilterParam(QUERYABLES)]``.
    An absent ``filter`` resolves to ``None``. ``filter-lang`` selects the encoding (else
    it is inferred); ``filter-crs`` is validated against ``crs_allowed``. A parse/validation
    failure, an unknown language, an unsupported CRS, or a reference to a non-queryable
    property each become a ``400`` problem.
    """
    resolved_engine = engine if engine is not None else _default_engine()
    allowed = tuple(crs_allowed)

    async def _filter_dep(
        value: str | None = Query(default=None, alias=name),
        lang_value: str | None = Query(default=None, alias=lang_name),
        crs_value: str | None = Query(default=None, alias=crs_name),
    ) -> Filter | None:
        if value is None:
            return None
        lang = _resolve_lang(value, lang_value, lang_name=lang_name)
        crs = validate_crs(crs_value, allowed, parameter=crs_name)
        try:
            compiled = resolved_engine.compile(value, lang)
            flt = Filter(compiled, lang, crs=crs)
            validate_properties(flt, queryables)
        except FilterError as exc:
            raise ParamError(name, str(exc)) from exc
        return flt

    return Depends(_filter_dep)

SortByParam

SortByParam(
    sortables: Sortables | Iterable[str],
    *,
    name: str = 'sortby',
) -> Any

Build a Depends resolving the OGC/STAC sortby parameter into a :class:SortBy.

sortables is the allow-list (a :class:Sortables resource or a set of field names); a term naming a field outside it — or malformed sortby syntax — is a 400 problem. An absent sortby resolves to None.

Source code in src/gazebo/ext/fastapi/filtering.py
def SortByParam(  # noqa: N802  (factory returning a Depends, named like one)
    sortables: Sortables | Iterable[str],
    *,
    name: str = 'sortby',
) -> Any:
    """Build a ``Depends`` resolving the OGC/STAC ``sortby`` parameter into a :class:`SortBy`.

    ``sortables`` is the allow-list (a :class:`Sortables` resource or a set of field names);
    a term naming a field outside it — or malformed ``sortby`` syntax — is a ``400``
    problem. An absent ``sortby`` resolves to ``None``.
    """
    names = sortables.names if isinstance(sortables, Sortables) else set(sortables)

    async def _sortby_dep(
        value: str | None = Query(default=None, alias=name),
    ) -> SortBy | None:
        if value is None:
            return None
        return SortBy.parse(value, sortables=names)

    return Depends(_sortby_dep)

inject_signature

inject_signature(
    endpoint: Callable[..., Any],
) -> Callable[..., Any]

Rewrite endpoint so injectable params resolve from the DI scope.

Injectable parameters are discovered via :func:_candidate_params (which resolves each annotation independently and leniently), so an injectable parameter still wires even when a sibling annotation cannot be resolved. Idempotent: include_router re-invokes this on the same endpoint, so the first application is recorded and later calls return early.

Source code in src/gazebo/ext/fastapi/injection.py
def inject_signature(endpoint: Callable[..., Any]) -> Callable[..., Any]:
    """Rewrite ``endpoint`` so injectable params resolve from the DI scope.

    Injectable parameters are discovered via :func:`_candidate_params` (which resolves
    each annotation independently and leniently), so an injectable parameter still wires
    even when a *sibling* annotation cannot be resolved. Idempotent: ``include_router``
    re-invokes this on the same endpoint, so the first application is recorded and later
    calls return early.
    """
    if getattr(endpoint, _INJECTED_FLAG, False):
        return endpoint

    injected: dict[str, inspect.Parameter] = {}
    unresolved: list[str] = []
    for cand in _candidate_params(endpoint):
        if not cand.resolved:
            unresolved.append(cand.name)
        if _is_injectable(cand.base, cand.meta):
            injected[cand.name] = cand.param.replace(
                kind=inspect.Parameter.KEYWORD_ONLY,
                default=Depends(_make_resolver(Key(cand.base, cand.qualifier))),  # type: ignore[arg-type]
                annotation=cand.base,
            )

    if unresolved:
        # A name we cannot resolve might have been injectable; we cannot tell, so it is
        # left un-wired (and FastAPI cannot type it either). Surface it rather than let
        # it become a request-time 500 — the usual cause is an import kept only under
        # ``if TYPE_CHECKING:``.
        qualname = getattr(endpoint, '__qualname__', repr(endpoint))
        names = ', '.join(unresolved)
        warnings.warn(
            f'gazebo could not resolve the type hint for parameter(s) {names} on route '
            f'handler {qualname!r}; if meant to be injected they will NOT be wired. '
            f'Import the annotated types at runtime, not only under TYPE_CHECKING.',
            stacklevel=2,
        )

    new_signature: inspect.Signature | None = None
    if injected:
        # Rewritten params become KEYWORD_ONLY, which must follow the kept positional
        # params (and precede ``**kwargs``) or ``Signature`` rejects the ordering.
        sig = inspect.signature(endpoint)
        kept = [
            p
            for n, p in sig.parameters.items()
            if n not in injected and p.kind is not p.VAR_KEYWORD
        ]
        var_keyword = [p for p in sig.parameters.values() if p.kind is p.VAR_KEYWORD]
        new_signature = sig.replace(parameters=[*kept, *injected.values(), *var_keyword])

    # Both writes target the endpoint object; guard them together so an exotic callable
    # that rejects attribute assignment falls back to FastAPI's own handling uniformly.
    with contextlib.suppress(AttributeError, TypeError):
        if new_signature is not None:
            endpoint.__signature__ = new_signature  # type: ignore[attr-defined]
        setattr(endpoint, _INJECTED_FLAG, True)
    return endpoint

CrsParam

CrsParam(
    allowed: Sequence[str] = (CRS84,),
    *,
    name: str = 'crs',
    default: str | None = None,
) -> Any

Build a Depends validating a crs/bbox-crs URI against an allow-list.

Pass name='bbox-crs' for the companion parameter. A value outside allowed raises ParamError (-> 400). When the parameter is absent, it resolves to:

  • the explicit default (which must be in allowed), if given; else
  • :data:~gazebo.params.CRS84 — the OGC default output CRS — if it is allowed; else
  • nothing: with a non-default allow-list and no marked default there is no safe assumption, so the parameter is required and an absent value is a 400.
Source code in src/gazebo/ext/fastapi/params.py
def CrsParam(  # noqa: N802  (factory returning a Depends, named like one)
    allowed: Sequence[str] = (CRS84,),
    *,
    name: str = 'crs',
    default: str | None = None,
) -> Any:
    """Build a ``Depends`` validating a ``crs``/``bbox-crs`` URI against an allow-list.

    Pass ``name='bbox-crs'`` for the companion parameter. A value outside ``allowed``
    raises ``ParamError`` (-> 400). When the parameter is **absent**, it resolves to:

    - the explicit ``default`` (which must be in ``allowed``), if given; else
    - :data:`~gazebo.params.CRS84` — the OGC default output CRS — if it is allowed; else
    - nothing: with a non-default allow-list and no marked default there is no safe
      assumption, so the parameter is **required** and an absent value is a 400.
    """
    allowed_uris = tuple(allowed)
    if not allowed_uris:
        raise ValueError('CrsParam requires at least one allowed CRS')
    if default is not None and default not in allowed_uris:
        raise ValueError(f'CrsParam default {default!r} is not in allowed')
    resolved_default = default or (CRS84 if CRS84 in allowed_uris else None)

    # ``Query`` is passed as the runtime default (not embedded in the annotation):
    # under ``from __future__ import annotations`` an ``Annotated[..., Query(alias=name)]``
    # string can't be resolved by ``get_type_hints`` because ``name`` is a closure
    # variable, which silently drops the query binding.
    async def _crs_dep(
        value: str | None = Query(
            default=None,
            alias=name,
            description=CRS_DESCRIPTION,
            # The allow-list is a closed set; emit it as an OpenAPI enum.
            json_schema_extra={'enum': list(allowed_uris)},
        ),
    ) -> str:
        # validate_crs owns the full resolution: an absent value resolves to
        # resolved_default (or 400s when that is None), a present one is checked
        # against the allow-list.
        return validate_crs(value, allowed_uris, parameter=name, default=resolved_default)

    return Depends(_crs_dep)

Negotiate

Negotiate(
    available: Sequence[Representation],
    *,
    default: Representation | None = None,
    name: str = 'f',
) -> Any

Build a Depends resolving the negotiated representation from ?f=/Accept.

Drop the result into a route as rep: Annotated[Representation, Negotiate([JSON, HTML])]: the endpoint then branches on rep (e.g. render HTML vs return the model) and can attach :func:~gazebo.negotiation.alternate_links. An unknown ?f= becomes a 400 and an unsatisfiable Accept a 406, both as problem+json.

Source code in src/gazebo/ext/fastapi/params.py
def Negotiate(  # noqa: N802  (factory returning a Depends, named like one)
    available: Sequence[Representation],
    *,
    default: Representation | None = None,
    name: str = 'f',
) -> Any:
    """Build a ``Depends`` resolving the negotiated representation from ``?f=``/``Accept``.

    Drop the result into a route as ``rep: Annotated[Representation, Negotiate([JSON,
    HTML])]``: the endpoint then branches on ``rep`` (e.g. render HTML vs return the
    model) and can attach :func:`~gazebo.negotiation.alternate_links`. An unknown ``?f=``
    becomes a ``400`` and an unsatisfiable ``Accept`` a ``406``, both as problem+json.
    """
    reps = tuple(available)

    # Query is the runtime default (not embedded in the annotation) for the same reason
    # as CrsParam: under `from __future__ import annotations` a closure-variable alias
    # can't be resolved by get_type_hints, which would drop the binding.
    async def _negotiate_dep(
        request: Request,
        value: str | None = Query(
            default=None,
            alias=name,
            # The available reps are known here, so name their actual keys.
            description=f_description(rep.key for rep in reps),
            # The representation keys are a closed set; emit them as an OpenAPI enum.
            json_schema_extra={'enum': [rep.key for rep in reps]},
        ),
    ) -> Representation:
        return negotiate(
            reps,
            f=value,
            accept=request.headers.get('accept'),
            default=default,
            f_param=name,
        )

    # Publish the reps on the dependency callable so route registration can discover them
    # (GazeboRouter / upgrade()'s add_api_route) and fold an OpenAPI response content map
    # documenting every negotiated media type — one source of truth with `negotiate`.
    _negotiate_dep.__gazebo_representations__ = reps  # type: ignore[attr-defined]

    return Depends(_negotiate_dep)

install_problem_handlers

install_problem_handlers(
    app: FastAPI,
    *,
    query_problem: ProblemType | None = None,
    body_problem: ProblemType | None = None,
) -> None

Register gazebo's problem exception handlers on app.

Handles :class:~gazebo.problems.ProblemException, FastAPI's :class:RequestValidationError (a 400 for a malformed query parameter, else 422), and gazebo's :class:~gazebo.params.ParamError (a 400).

The validation/param problems are typeless (about:blank) unless a :class:~gazebo.problems.ProblemType is supplied to give those framework errors a resolvable type/title from the service's own catalog:

  • query_problem — the 400 case (malformed query parameter); must have status == 400.
  • body_problem — the 422 case (bad request body/path); must have status == 422.

The handler stays authoritative for the response status, the detail, and the errors/parameter/parameters extension members; a supplied type only sets type/title.

Source code in src/gazebo/ext/fastapi/problems.py
def install_problem_handlers(
    app: FastAPI,
    *,
    query_problem: ProblemType | None = None,
    body_problem: ProblemType | None = None,
) -> None:
    """Register gazebo's problem exception handlers on ``app``.

    Handles :class:`~gazebo.problems.ProblemException`, FastAPI's
    :class:`RequestValidationError` (a 400 for a malformed query parameter, else 422), and
    gazebo's :class:`~gazebo.params.ParamError` (a 400).

    The validation/param problems are typeless (``about:blank``) unless a
    :class:`~gazebo.problems.ProblemType` is supplied to give those framework errors a
    resolvable ``type``/``title`` from the service's own catalog:

    - ``query_problem`` — the 400 case (malformed query parameter); must have ``status == 400``.
    - ``body_problem`` — the 422 case (bad request body/path); must have ``status == 422``.

    The handler stays authoritative for the response ``status``, the ``detail``, and the
    ``errors``/``parameter``/``parameters`` extension members; a supplied type only sets
    ``type``/``title``.
    """
    if query_problem is not None and query_problem.status != 400:
        raise ValueError(f'query_problem must have status 400, got {query_problem.status}')
    if body_problem is not None and body_problem.status != 422:
        raise ValueError(f'body_problem must have status 422, got {body_problem.status}')

    async def on_validation_error(request: Request, exc: RequestValidationError) -> Response:
        return _problem_response(
            _validation_problem(exc, query_problem=query_problem, body_problem=body_problem),
        )

    async def on_param_error(request: Request, exc: ParamError) -> Response:
        return _problem_response(_param_problem(exc, query_problem=query_problem))

    app.add_exception_handler(ProblemException, problem_exception_handler)  # type: ignore[arg-type]
    app.add_exception_handler(RequestValidationError, on_validation_error)  # type: ignore[arg-type]
    app.add_exception_handler(ParamError, on_param_error)  # type: ignore[arg-type]

not_modified

not_modified(
    request: Request,
    *,
    etag: str | None = None,
    last_modified: datetime | None = None,
    cache_control: str | None = None,
) -> Response | None

Return a 304 Not Modified response if the request's preconditions match.

Reads If-None-Match / If-Modified-Since from request and compares them against the supplied etag / last_modified (see :func:gazebo.caching.is_not_modified for the precedence rules). Returns a ready 304 carrying the validators when they match, else None — so the caller proceeds to build the full response.

Pass the same cache_control you set on the 200 path: per RFC 9111 §4.3.4 a 304 should refresh the cache's freshness directives, so omitting it would make a revalidating cache fall back to stale or more-conservative behavior::

@router.get('/thing', response_model=Thing)
async def thing(request: Request, response: Response):
    obj = load_thing()
    tag = etag_for(obj)
    if (resp := not_modified(request, etag=tag, cache_control='max-age=60')) is not None:
        return resp
    set_cache_headers(response, etag=tag, cache_control='max-age=60')
    return obj
Source code in src/gazebo/ext/fastapi/responses.py
def not_modified(
    request: Request,
    *,
    etag: str | None = None,
    last_modified: datetime | None = None,
    cache_control: str | None = None,
) -> Response | None:
    """Return a ``304 Not Modified`` response if the request's preconditions match.

    Reads ``If-None-Match`` / ``If-Modified-Since`` from ``request`` and compares them
    against the supplied ``etag`` / ``last_modified`` (see
    :func:`gazebo.caching.is_not_modified` for the precedence rules). Returns a ready
    ``304`` carrying the validators when they match, else ``None`` — so the caller
    proceeds to build the full response.

    Pass the same ``cache_control`` you set on the ``200`` path: per RFC 9111 §4.3.4 a
    ``304`` should refresh the cache's freshness directives, so omitting it would make a
    revalidating cache fall back to stale or more-conservative behavior::

        @router.get('/thing', response_model=Thing)
        async def thing(request: Request, response: Response):
            obj = load_thing()
            tag = etag_for(obj)
            if (resp := not_modified(request, etag=tag, cache_control='max-age=60')) is not None:
                return resp
            set_cache_headers(response, etag=tag, cache_control='max-age=60')
            return obj
    """
    if is_not_modified(
        method=request.method,
        etag=etag,
        last_modified=last_modified,
        if_none_match=request.headers.get('if-none-match'),
        if_modified_since=request.headers.get('if-modified-since'),
    ):
        headers: dict[str, str] = {}
        if etag is not None:
            headers['ETag'] = etag
        if last_modified is not None:
            headers['Last-Modified'] = http_date(last_modified)
        if cache_control is not None:
            headers['Cache-Control'] = cache_control
        return Response(status_code=304, headers=headers)
    return None

set_cache_headers

set_cache_headers(
    response: Response,
    *,
    etag: str | None = None,
    last_modified: datetime | None = None,
    cache_control: str | None = None,
) -> None

Stamp ETag / Last-Modified / Cache-Control onto response.

The companion to :func:not_modified: set the validators on the success response so the next request can be made conditional.

Source code in src/gazebo/ext/fastapi/responses.py
def set_cache_headers(
    response: Response,
    *,
    etag: str | None = None,
    last_modified: datetime | None = None,
    cache_control: str | None = None,
) -> None:
    """Stamp ``ETag`` / ``Last-Modified`` / ``Cache-Control`` onto ``response``.

    The companion to :func:`not_modified`: set the validators on the success response
    so the *next* request can be made conditional.
    """
    if etag is not None:
        response.headers['ETag'] = etag
    if last_modified is not None:
        response.headers['Last-Modified'] = http_date(last_modified)
    if cache_control is not None:
        response.headers['Cache-Control'] = cache_control
set_link_header(
    response: Response,
    links: Sequence[Link],
    *,
    rels: Sequence[str] | None = NAV_RELS,
    max_links: int = DEFAULT_MAX_LINKS,
) -> None

Set an RFC 8288 Link header on response from links.

A peer of :func:set_cache_headers: call it inside an endpoint to mirror a response's navigational links into a Link header, so non-JSON clients and crawlers can follow them without parsing the body. links is any sequence of :class:~gazebo.link.Link (a collection envelope's .links, or a hand-built list) — it is not tied to any response type.

Deferred (callable) hrefs are resolved against the active request context, so this must be called within a request. Only navigational rels (:data:NAV_RELS) are emitted by default and the count is capped at max_links; pass rels=None to include every rel. Sets nothing when nothing qualifies.

Source code in src/gazebo/ext/fastapi/responses.py
def set_link_header(
    response: Response,
    links: Sequence[Link],
    *,
    rels: Sequence[str] | None = NAV_RELS,
    max_links: int = DEFAULT_MAX_LINKS,
) -> None:
    """Set an RFC 8288 ``Link`` header on ``response`` from ``links``.

    A peer of :func:`set_cache_headers`: call it inside an endpoint to mirror a
    response's navigational links into a ``Link`` header, so non-JSON clients and
    crawlers can follow them without parsing the body. ``links`` is **any** sequence of
    :class:`~gazebo.link.Link` (a collection envelope's ``.links``, or a hand-built
    list) — it is not tied to any response type.

    Deferred (callable) hrefs are resolved against the active request context, so this
    must be called within a request. Only navigational rels (:data:`NAV_RELS`) are
    emitted by default and the count is capped at ``max_links``; pass ``rels=None`` to
    include every rel. Sets nothing when nothing qualifies.
    """
    dumped = [link.model_dump(mode='json') for link in links]
    header = format_link_header(dumped, rels=rels, max_links=max_links)
    if header:
        response.headers['Link'] = header

CLI / serving

gazebo.ext.cli

Server-agnostic CLI toolkit: self-documenting settings options for a serve command.

This is a topmost, optional layer — like ext/fastapi it sits above the core and must not be imported by it. It imports click and pydantic-settings only (never a web server); :mod:gazebo.ext.uvicorn builds the batteries-included uvicorn serve command on top of these pieces, but you can compose the same pieces atop any server (granian, hypercorn, ...) with no uvicorn dependency. Requires the gazebo[cli] extra.

The building blocks:

  • :class:SettingsGroup — one or more settings classes composed into a validated set of documented, self-propagating click.Option\ s (one per field), so --help shows every setting, its env var, default, and description (the self-documentation), and a passed option writes its env var so the value reaches the app (and any server workers) through the environment they already read. No serialization, no transport across the worker boundary. Combine groups with +.
  • :func:secrets_epilog — the --help epilog documenting secret fields (their env var, requiredness) without accepting them as flags, so a composed command can document secrets without ever putting them on the command line.
  • :class:JsonFormatter — a minimal structured (one-JSON-object-per-line) logging formatter with no server dependency; :func:gazebo.ext.uvicorn.default_log_config wires it into a complete dictConfig for uvicorn.

A settings option is self-propagating — it carries a callback that writes its env var when passed — so you can drop it onto your own click command (renamed, reordered, alongside your own options) and it still reaches the app with no export step of your own.

Secrets are never accepted on the command line: model them as SecretStr and they get no value flag, only a documented entry in --help (their env var) via :func:secrets_epilog, so they stay out of shell history / ps. Supply them via the settings class's secrets_dir (pydantic-settings reads /run/secrets-style files) or env.

JsonFormatter

Bases: Formatter

Minimal structured formatter: one JSON object per line. The access logger's rendered line lands in message; fully-structured access fields (status, path as separate keys) are a later enhancement if needed.

Source code in src/gazebo/ext/cli.py
class JsonFormatter(logging.Formatter):
    """Minimal structured formatter: one JSON object per line. The access logger's
    rendered line lands in ``message``; fully-structured access fields (status, path
    as separate keys) are a later enhancement if needed."""

    def format(self, record: logging.LogRecord) -> str:
        data: dict[str, Any] = {
            'time': self.formatTime(record),
            'level': record.levelname,
            'logger': record.name,
            'message': record.getMessage(),
        }
        rid = getattr(record, 'request_id', None)
        if rid is not None:
            data['request_id'] = rid
        if record.exc_info:
            data['exc'] = self.formatException(record.exc_info)
        return json.dumps(data, default=str)

SettingsGroup

One or more settings classes composed into a validated set of self-documenting CLI options.

Construct one from a pydantic-settings class to expose its non-secret fields as click options — each prefixed by the class's (required) env_prefix (e.g. --app-host), carrying its env var for --help, and writing that env var when passed so the value reaches the app (and any server workers) with no export step of your own. The options are expose_value=False: they act purely by side effect.

Combine groups with + to expose several on one command (SettingsGroup(A) + SettingsGroup(B)); the result is another SettingsGroup. Constructing or combining validates the whole set: each group needs a distinct env_prefix and no two options may share a flag. Pass the group to :func:gazebo.ext.uvicorn.serve_command, or splat :attr:options onto your own click command.

Parameters:

Name Type Description Default
settings_cls type[BaseSettings] | None

The pydantic-settings class to document. Omit for an empty group.

None
exclude Collection[str]

Generated flags to omit, e.g. {'--app-log-level'} — drop a field you pin to a constant (set its env var yourself, or leave the app's default).

()
rename Mapping[str, str | Sequence[str]] | None

{generated_flag: decl} to give an option a different flag, e.g. {'--app-greeting': '--message'}, to unify names across a larger CLI. The key is the flag this would generate — reading in the same namespace as the replacement, not the bare field name — and the env var is unchanged, so the renamed option still propagates to the same field. decl may be a single string or a sequence of declarations (['-C', '--config']) to add a short option. A renamed bool gets the usual --x/--no-x toggle automatically; give the full '--x/--no-x' form to control both names.

None

Raises:

Type Description
ValueError

If an exclude/rename key matches no generated flag (a typo, or a secret field, which has no option), if a group lacks an env_prefix, or if the composition has a duplicate prefix or a flag collision.

Source code in src/gazebo/ext/cli.py
class SettingsGroup:
    """One or more settings classes composed into a validated set of self-documenting CLI
    options.

    Construct one from a ``pydantic-settings`` class to expose its non-secret fields as
    ``click`` options — each prefixed by the class's (required) ``env_prefix`` (e.g.
    ``--app-host``), carrying its env var for ``--help``, and writing that env var when
    passed so the value reaches the app (and any server workers) with no export step of
    your own. The options are ``expose_value=False``: they act purely by side effect.

    Combine groups with ``+`` to expose several on one command
    (``SettingsGroup(A) + SettingsGroup(B)``); the result is another ``SettingsGroup``.
    Constructing or combining validates the whole set: each group needs a distinct
    ``env_prefix`` and no two options may share a flag. Pass the group to
    :func:`gazebo.ext.uvicorn.serve_command`, or splat :attr:`options` onto your own
    ``click`` command.

    Args:
        settings_cls: The ``pydantic-settings`` class to document. Omit for an empty group.
        exclude: Generated flags to omit, e.g. ``{'--app-log-level'}`` — drop a field you
            pin to a constant (set its env var yourself, or leave the app's default).
        rename: ``{generated_flag: decl}`` to give an option a different flag, e.g.
            ``{'--app-greeting': '--message'}``, to unify names across a larger CLI. The
            key is the flag this would generate — reading in the same namespace as the
            replacement, not the bare field name — and the env var is unchanged, so the
            renamed option still propagates to the same field. ``decl`` may be a single
            string or a sequence of declarations (``['-C', '--config']``) to add a short
            option. A renamed ``bool`` gets the usual ``--x/--no-x`` toggle automatically;
            give the full ``'--x/--no-x'`` form to control both names.

    Raises:
        ValueError: If an ``exclude``/``rename`` key matches no generated flag (a typo, or
            a secret field, which has no option), if a group lacks an ``env_prefix``, or if
            the composition has a duplicate prefix or a flag collision.
    """

    def __init__(
        self,
        settings_cls: type[BaseSettings] | None = None,
        *,
        exclude: Collection[str] = (),
        rename: Mapping[str, str | Sequence[str]] | None = None,
    ) -> None:
        self._groups: tuple[_Group, ...] = ()
        if settings_cls is not None:
            opts = _group_options(settings_cls, exclude, rename)
            self._groups = (_Group(settings_cls, opts),)
        _validate_groups(self._groups)

    @classmethod
    def _combined(cls, groups: tuple[_Group, ...]) -> SettingsGroup:
        self = cls.__new__(cls)
        self._groups = groups
        _validate_groups(groups)
        return self

    def __add__(self, other: object) -> SettingsGroup:
        if not isinstance(other, SettingsGroup):
            return NotImplemented
        return SettingsGroup._combined(self._groups + other._groups)

    @property
    def options(self) -> list[click.Parameter]:
        """The composed ``click`` options — one per non-secret field across every group."""
        return [opt for group in self._groups for opt in group.options]

    @property
    def settings_classes(self) -> tuple[type[BaseSettings], ...]:
        """The settings classes in composition order (for ``--check`` and secret docs)."""
        return tuple(group.settings_cls for group in self._groups)

    @property
    def secrets_epilog(self) -> str | None:
        """A ``--help`` epilog documenting every group's secret fields (their env var,
        requiredness) without a value flag, or ``None`` if no group has a secret. See
        :func:`secrets_epilog`."""
        classes = self.settings_classes
        return secrets_epilog(classes) if classes else None

options property

options: list[Parameter]

The composed click options — one per non-secret field across every group.

settings_classes property

settings_classes: tuple[type[BaseSettings], ...]

The settings classes in composition order (for --check and secret docs).

secrets_epilog property

secrets_epilog: str | None

A --help epilog documenting every group's secret fields (their env var, requiredness) without a value flag, or None if no group has a secret. See :func:secrets_epilog.

secrets_epilog

secrets_epilog(
    settings: type[BaseSettings]
    | Sequence[type[BaseSettings]],
) -> str | None

Render a --help epilog documenting secret fields as a configuration surface — their env vars, but no value-accepting flag, so secrets never land in shell history or ps. Supply them via the environment or the class's secrets_dir.

Returns None when no class declares a secret field, so a composed command can use it directly as its epilog regardless.

Parameters:

Name Type Description Default
settings type[BaseSettings] | Sequence[type[BaseSettings]]

A single pydantic-settings class or a sequence of them. A required secret is marked (required) since it has no flag to carry the marker.

required
Source code in src/gazebo/ext/cli.py
def secrets_epilog(
    settings: type[BaseSettings] | Sequence[type[BaseSettings]],
) -> str | None:
    """Render a ``--help`` epilog documenting secret fields as a configuration surface —
    their env vars, but no value-accepting flag, so secrets never land in shell history
    or ``ps``. Supply them via the environment or the class's ``secrets_dir``.

    Returns ``None`` when no class declares a secret field, so a composed command can use
    it directly as its ``epilog`` regardless.

    Args:
        settings: A single ``pydantic-settings`` class or a sequence of them. A required
            secret is marked ``(required)`` since it has no flag to carry the marker.
    """
    classes = (settings,) if isinstance(settings, type) else tuple(settings)
    rows: list[tuple[str, str]] = []
    for cls in classes:
        prefix = cls.model_config.get('env_prefix', '')
        case_sensitive = cls.model_config.get('case_sensitive', False)
        for name, field in cls.model_fields.items():
            if not _is_secret(field.annotation):
                continue
            envvar = prefix + name
            desc = field.description or ''
            if field.is_required():  # no flag to mark required, so say so here
                desc = f'{desc} (required)'.strip()
            rows.append((envvar if case_sensitive else envvar.upper(), desc))
    if not rows:
        return None
    width = max(len(envvar) for envvar, _ in rows)
    listing = '\n'.join(f'  {envvar.ljust(width)}  {desc}'.rstrip() for envvar, desc in rows)
    # the \b marks the listing as preformatted so click doesn't rewrap the columns
    return (
        'Secrets (set via the environment or a secrets file, never on the command '
        'line):\n\n\b\n' + listing
    )

gazebo.ext.uvicorn

Uvicorn assembly for the serve command: an argv-boundary over uvicorn's CLI.

This sits above :mod:gazebo.ext.cli (the server-agnostic toolkit) and is the only module here that imports uvicorn. It treats uvicorn as a CLI, not a library: the only execution-path coupling is uvicorn's documented command-line interface. Instead of copying uvicorn's option params and delegating to its callback (three couplings to uvicorn internals), :func:serve forwards documented argv to uvicorn.main.main(args=..., standalone_mode=False) and lets uvicorn do its own parsing, defaults, UVICORN_* env vars, and value transforms.

Discoverability splits cleanly: serve --help documents app configuration (our contribution via :class:gazebo.ext.cli.SettingsGroup), while serve --help-server prints uvicorn's own help. The --help-server path is a display-only coupling (uvicorn.main.get_help) that fails soft — a broken help screen never stops a server.

This module also owns :func:default_log_config, the batteries-included dictConfig: it configures the uvicorn/uvicorn.error/uvicorn.access loggers by name and references uvicorn.logging.DefaultFormatter/AccessFormatter, so — unlike the rest of gazebo.ext.cli — it's genuinely uvicorn-specific and lives here instead.

Requires the gazebo[uvicorn] extra (which pulls in gazebo[cli] plus uvicorn itself). The import uvicorn here resolves to the real package, mirroring ext/fastapi's import fastapi.

default_log_config

default_log_config(
    level: str = 'INFO',
    *,
    json_logs: bool = False,
    request_id: bool = False,
) -> dict[str, Any]

A complete dictConfig so uvicorn's loggers coexist with app loggers (disable_existing_loggers=False), error + access are formatted consistently, and it re-applies cleanly in every spawned worker.

The console (non-json_logs) format names uvicorn.logging.DefaultFormatter / AccessFormatter as () dictConfig strings, resolved by logging.config at config time; this module already requires uvicorn, so that's a real dependency, not a lazy one. The json_logs mode uses only :class:gazebo.ext.cli.JsonFormatter and has no further uvicorn dependency.

Parameters:

Name Type Description Default
level str

Log level for the server loggers and the root logger.

'INFO'
json_logs bool

Emit one JSON object per line (for log aggregation) instead of the console format. Pin per environment, e.g. log_config=default_log_config(json_logs=True).

False
request_id bool

Wire :class:gazebo.requestid.RequestIdFilter into the handlers so the per-request id (set via use_request_id) appears in every line.

False
Source code in src/gazebo/ext/uvicorn.py
def default_log_config(
    level: str = 'INFO',
    *,
    json_logs: bool = False,
    request_id: bool = False,
) -> dict[str, Any]:
    """A complete dictConfig so uvicorn's loggers coexist with app loggers
    (``disable_existing_loggers=False``), error + access are formatted consistently,
    and it re-applies cleanly in every spawned worker.

    The console (non-``json_logs``) format names ``uvicorn.logging.DefaultFormatter`` /
    ``AccessFormatter`` as ``()`` dictConfig strings, resolved by ``logging.config`` at
    config time; this module already requires uvicorn, so that's a real dependency, not
    a lazy one. The ``json_logs`` mode uses only :class:`gazebo.ext.cli.JsonFormatter`
    and has no further uvicorn dependency.

    Args:
        level: Log level for the server loggers and the root logger.
        json_logs: Emit one JSON object per line (for log aggregation) instead of
            the console format. Pin per environment, e.g.
            ``log_config=default_log_config(json_logs=True)``.
        request_id: Wire :class:`gazebo.requestid.RequestIdFilter` into the handlers so
            the per-request id (set via ``use_request_id``) appears in every line.
    """
    rid_token = '[%(request_id)s] ' if request_id else ''
    formatters: dict[str, dict[str, Any]]
    if json_logs:
        formatters = {
            'default': {'()': 'gazebo.ext.cli.JsonFormatter'},
            'access': {'()': 'gazebo.ext.cli.JsonFormatter'},
        }
    else:
        formatters = {
            'default': {
                '()': 'uvicorn.logging.DefaultFormatter',
                'format': f'%(levelprefix)s {rid_token}%(message)s',
                'use_colors': None,
            },
            'access': {
                '()': 'uvicorn.logging.AccessFormatter',
                'format': f'%(levelprefix)s {rid_token}%(client_addr)s - '
                '"%(request_line)s" %(status_code)s',
            },
        }
    filters = {'request_id': {'()': 'gazebo.requestid.RequestIdFilter'}} if request_id else {}
    handler_filters = ['request_id'] if request_id else []
    return {
        'version': 1,
        'disable_existing_loggers': False,
        'filters': filters,
        'formatters': formatters,
        'handlers': {
            'default': {
                'class': 'logging.StreamHandler',
                'formatter': 'default',
                'stream': 'ext://sys.stderr',
                'filters': handler_filters,
            },
            'access': {
                'class': 'logging.StreamHandler',
                'formatter': 'access',
                'stream': 'ext://sys.stdout',
                'filters': handler_filters,
            },
        },
        'loggers': {
            'uvicorn': {'handlers': ['default'], 'level': level, 'propagate': False},
            'uvicorn.error': {'level': level},
            'uvicorn.access': {'handlers': ['access'], 'level': level, 'propagate': False},
        },
        'root': {'handlers': ['default'], 'level': level},
    }

serve

serve(
    app: str | Any,
    *uvicorn_args: str,
    factory: bool = False,
    log_config: Any = None,
) -> None

Launch app via uvicorn by forwarding documented CLI argv to its console entry point (uvicorn.main.main(args=..., standalone_mode=False)).

uvicorn_args are exactly uvicorn's documented command-line arguments, so serve('pkg.mod:app', '--workers', '4') mirrors uvicorn pkg.mod:app --workers 4. Uvicorn does its own parsing, defaults, UVICORN_* env vars, and value transforms (--header x:y splitting, --app-dir on sys.path, ...). An unknown flag gets uvicorn's own error (with its "did you mean" suggestion): raised as a :class:click.UsageError when called outside a click context, and surfaced as a normal usage error when called inside one.

Parameters:

Name Type Description Default
app str | Any

A 'module:attr' import string or an importable (module-level) factory. Live app objects and lambdas are rejected — uvicorn re-imports by name.

required
*uvicorn_args str

Uvicorn CLI arguments, forwarded verbatim after the injected --factory / --log-config. Since click is last-value-wins for single-value options, an explicit --log-config here overrides the injected default.

()
factory bool

Force factory mode for a string app (auto-detected for callables).

False
log_config Any

dictConfig loading for uvicorn. None injects :func:default_log_config; a dict is written to a temp .json file whose path is passed (workers re-read it); a str/Path is passed through as-is.

None
Source code in src/gazebo/ext/uvicorn.py
def serve(
    app: str | Any,
    *uvicorn_args: str,
    factory: bool = False,
    log_config: Any = None,
) -> None:
    """Launch ``app`` via uvicorn by forwarding documented CLI argv to its console entry
    point (``uvicorn.main.main(args=..., standalone_mode=False)``).

    ``uvicorn_args`` are exactly uvicorn's documented command-line arguments, so
    ``serve('pkg.mod:app', '--workers', '4')`` mirrors ``uvicorn pkg.mod:app --workers
    4``. Uvicorn does its own parsing, defaults, ``UVICORN_*`` env vars, and value
    transforms (``--header x:y`` splitting, ``--app-dir`` on ``sys.path``, ...). An
    unknown flag gets uvicorn's own error (with its "did you mean" suggestion): raised as
    a :class:`click.UsageError` when called outside a click context, and surfaced as a
    normal usage error when called inside one.

    Args:
        app: A ``'module:attr'`` import string or an importable (module-level) factory.
            Live app objects and lambdas are rejected — uvicorn re-imports by name.
        *uvicorn_args: Uvicorn CLI arguments, forwarded verbatim after the injected
            ``--factory`` / ``--log-config``. Since click is last-value-wins for
            single-value options, an explicit ``--log-config`` here overrides the
            injected default.
        factory: Force factory mode for a string ``app`` (auto-detected for callables).
        log_config: dictConfig loading for uvicorn. ``None`` injects
            :func:`default_log_config`; a ``dict`` is written to a temp
            ``.json`` file whose path is passed (workers re-read it); a ``str``/``Path``
            is passed through as-is.
    """
    import_string, derived_factory = _resolve_app(app)
    use_factory = factory or derived_factory

    temp_path: str | None = None
    if log_config is None:
        log_config = default_log_config()
    if isinstance(log_config, dict):
        # workers re-read this file, so it must live for the whole run (unlinked after).
        with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as fh:
            json.dump(log_config, fh)
            temp_path = fh.name
        log_config_path = temp_path
    else:
        log_config_path = str(log_config)

    argv = [import_string]
    if use_factory:
        argv.append('--factory')
    argv += ['--log-config', log_config_path]
    argv += list(uvicorn_args)  # last, so an explicit operator --log-config wins

    try:
        uvicorn.main.main(args=argv, standalone_mode=False)  # type: ignore[attr-defined]
    finally:
        if temp_path is not None:
            with suppress(OSError):
                Path(temp_path).unlink()

serve_command

serve_command(
    app: str | Any,
    *,
    settings_group: SettingsGroup | None = None,
    name: str = 'serve',
    factory: bool = False,
    log_config: Any = None,
    uvicorn_args: Sequence[str] = (),
) -> click.Command

Build a click serve command for app.

serve --help documents your app's configuration (one option per settings field); every uvicorn option is still accepted and forwarded verbatim to uvicorn (run serve --help-server to list them). Operator arguments on the command line follow the author's uvicorn_args defaults, so — being later — the operator can override them (uvicorn_args=('--workers', '4') is a default an operator overrides with --workers 8).

Parameters:

Name Type Description Default
app str | Any

A 'module:attr' import string or an importable (module-level) factory callable. Live app objects and lambdas are rejected because uvicorn workers re-import by name.

required
settings_group SettingsGroup | None

A :class:gazebo.ext.cli.SettingsGroup — one or more settings classes composed (and validated) into the command's options. Build it yourself so any per-group exclude/rename and its cross-group checks live in one place: settings_group=SettingsGroup(Settings) (or SettingsGroup(A) + SettingsGroup(B)). A passed option sets its env var.

None
name str

The command name (default serve).

'serve'
factory bool

Force factory mode for a string app (auto-detected for callables).

False
log_config Any

dictConfig for uvicorn; defaults to :func:default_log_config. Operators can still override it with --log-config on the command line.

None
uvicorn_args Sequence[str]

Author-supplied uvicorn CLI defaults, forwarded before operator arguments so an operator can override them at the command line.

()
Source code in src/gazebo/ext/uvicorn.py
def serve_command(
    app: str | Any,
    *,
    settings_group: SettingsGroup | None = None,
    name: str = 'serve',
    factory: bool = False,
    log_config: Any = None,
    uvicorn_args: Sequence[str] = (),
) -> click.Command:
    """Build a click ``serve`` command for ``app``.

    ``serve --help`` documents *your app's configuration* (one option per settings
    field); every uvicorn option is still accepted and forwarded verbatim to uvicorn (run
    ``serve --help-server`` to list them). Operator arguments on the command line follow
    the author's ``uvicorn_args`` defaults, so — being later — the operator can override
    them (``uvicorn_args=('--workers', '4')`` is a default an operator overrides with
    ``--workers 8``).

    Args:
        app: A ``'module:attr'`` import string or an importable (module-level)
            factory callable. Live app objects and lambdas are rejected because
            uvicorn workers re-import by name.
        settings_group: A :class:`gazebo.ext.cli.SettingsGroup` — one or more settings
            classes composed (and validated) into the command's options. Build it
            yourself so any per-group ``exclude``/``rename`` and its cross-group checks
            live in one place: ``settings_group=SettingsGroup(Settings)`` (or
            ``SettingsGroup(A) + SettingsGroup(B)``). A passed option sets its env var.
        name: The command name (default ``serve``).
        factory: Force factory mode for a string ``app`` (auto-detected for callables).
        log_config: dictConfig for uvicorn; defaults to
            :func:`default_log_config`. Operators can still override it
            with ``--log-config`` on the command line.
        uvicorn_args: Author-supplied uvicorn CLI defaults, forwarded *before* operator
            arguments so an operator can override them at the command line.
    """
    import_string, derived_factory = _resolve_app(app)
    force_factory = factory or derived_factory

    group = settings_group if settings_group is not None else SettingsGroup()
    setting_opts = group.options
    classes = group.settings_classes

    check_opt = click.Option(
        ['--check'],
        is_flag=True,
        default=False,
        help='Validate settings and that the app imports, then exit (no server).',
    )

    def _show_server_help(ctx: click.Context, param: click.Parameter, value: bool) -> None:
        # eager (like --version): must work even if a required settings option is unset.
        if not value or ctx.resilient_parsing:
            return
        click.echo(uvicorn.main.get_help(click.Context(uvicorn.main)))  # type: ignore[attr-defined]
        ctx.exit()

    help_server_opt = click.Option(
        ['--help-server'],
        is_flag=True,
        is_eager=True,
        expose_value=False,
        callback=_show_server_help,
        help="Show uvicorn's own options (all are accepted and forwarded), then exit.",
    )

    def callback(**kwargs: Any) -> None:
        if kwargs.pop('check', False):
            _run_check(import_string, classes)
            return
        # settings options self-propagate to the env (expose_value=False), so they never
        # reach here; forwarded uvicorn args land in ctx.args (unknown options allowed).
        ctx = click.get_current_context()
        serve(
            import_string,
            *uvicorn_args,
            *ctx.args,
            factory=force_factory,
            log_config=log_config,
        )

    epilog = group.secrets_epilog
    forwarded = (
        'Any uvicorn option (--workers, --reload, --host, ...) is accepted and '
        'forwarded to uvicorn; run with --help-server to list them.'
    )
    epilog = f'{epilog}\n\n{forwarded}' if epilog else forwarded

    return click.Command(
        name,
        params=[check_opt, help_server_opt, *setting_opts],
        callback=callback,
        epilog=epilog,
        context_settings={'ignore_unknown_options': True, 'allow_extra_args': True},
        help='Run the server. Options here configure the app (each sets its env var); '
        'uvicorn options are accepted and forwarded — see --help-server.',
    )