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[
    Annotated[
        UrlResolver,
        PlainSerializer(
            _resolve_href, return_type=str, when_used='json'
        ),
    ]
    | Annotated[Any, AfterValidator(_to_url)],
    Field(union_mode='left_to_right'),
    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

    # --- 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,
        **kwargs: Any,
    ) -> Self:
        """Link to a named route (resolved via ``ctx.url_for(name, **path)``).

        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.
            **kwargs: Extra link fields (e.g. ``title``).
        """
        path = path or {}
        return cls.model_validate(
            {
                'href': lambda ctx: ctx.url_for(name, **path),
                'rel': rel,
                'type': type,
                **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,
    **kwargs: Any,
) -> Self

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

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
**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,
    **kwargs: Any,
) -> Self:
    """Link to a named route (resolved via ``ctx.url_for(name, **path)``).

    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.
        **kwargs: Extra link fields (e.g. ``title``).
    """
    path = path or {}
    return cls.model_validate(
        {
            'href': lambda ctx: ctx.url_for(name, **path),
            'rel': rel,
            'type': type,
            **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

    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 = handler(self)
        if info.by_alias and self._items_alias != 'items':
            data = {(self._items_alias if k == 'items' else 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.
            data.pop('numberReturned' if info.by_alias else 'number_returned', 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 = {'items': cls._items_alias} if cls._items_alias != 'items' else {}
        drop = set() if cls._emit_number_returned else {'numberReturned', 'number_returned'}

        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 (omitted when None).

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.

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 (omitted when ``None``).
        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``.
        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.
    """
    opts: dict[str, Any] = {'method': method, 'body': body, 'type': type, 'headers': headers}

    def link(rel: str, token: str | None) -> Link:
        return _page_link(rel, {token_param: token, limit_param: limit}, extra=link_fields, **opts)

    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 (limit preserved).
        links.append(
            _page_link(
                Rel.FIRST,
                {token_param: None, limit_param: limit},
                extra=link_fields,
                **opts,
            ),
        )
    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, {}, extra=link_fields, **opts))
    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')

    opts: dict[str, Any] = {'method': method, 'body': body, 'type': type, 'headers': headers}

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

    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 = 'cursor'
) -> 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.

Source code in src/gazebo/pagination.py
def decode_cursor(token: str, *, parameter: str = 'cursor') -> 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.
    """
    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.

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.
    """
    header = header.strip()
    if header == '*':
        return True
    current = _normalize_etag(etag)
    return any(current == _normalize_etag(candidate) for candidate in header.split(','))

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

Request-context seam for deferred URL generation.

The core never imports a web framework. Link hrefs may be callables that need "the current request" to produce a URL; that request 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.

RequestContext

Bases: Protocol

The minimal surface link factories need to build URLs.

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.

Source code in src/gazebo/context.py
@runtime_checkable
class RequestContext(Protocol):
    """The minimal surface link factories need to build URLs.

    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.
    """

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

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

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

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

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/context.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

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

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. Other values are stringified. 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. Other values are stringified. The shared
    "derive a URL from the active context" helper behind deferred pagination and
    content-negotiation hrefs.
    """
    parts = urlsplit(ctx.url)
    query = dict(parse_qsl(parts.query, keep_blank_values=True))
    for key, value in overrides.items():
        if value is None:
            query.pop(key, None)
        else:
            query[key] = str(value)
    return urlunsplit(parts._replace(query=urlencode(query)))

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: BaseModel

An RFC 7807/9457 problem object. Extensions allowed.

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

    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: BaseModel

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(BaseModel):
    """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.

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)

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.

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.

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

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.

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.
        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 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)
            ]
            best_q = max(q for q, _, _ in scored)
            if best_q > 0:
                # highest q wins; ties fall to server-preferred order (lowest index)
                _, _, rep = min((-q, i, r) for q, i, r in scored)
                return rep
            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'
    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:
        return _get(list(scope.get('headers') or []), self._header) == 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.
    """

    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'):
            scope['scheme'] = _first(proto).decode('latin-1')

        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, ()

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.

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',
        **fastapi_kwargs: Any,
    ) -> None:
        super().__init__(**fastapi_kwargs)
        upgrade(
            self,
            providers,
            overrides=overrides,
            trust=trust,
            cors=cors,
            health_path=health_path,
        )

    @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)

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

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:
        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

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',
) -> 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.

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',
) -> 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.
    """
    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]
        providers.request(RequestContext, _provide_request_context)  # type: ignore[type-abstract]
    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:
        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)
    app.add_exception_handler(ProblemException, problem_exception_handler)  # type: ignore[arg-type]
    app.add_exception_handler(RequestValidationError, validation_exception_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ParamError, param_exception_handler)  # type: ignore[arg-type]

    previous_lifespan = app.router.lifespan_context

    @asynccontextmanager
    async def lifespan(a: FastAPI) -> AsyncIterator[None]:
        _validate_routes(a, container)
        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)) -> 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),
    ) -> Representation:
        return negotiate(
            reps,
            f=value,
            accept=request.headers.get('accept'),
            default=default,
            f_param=name,
        )

    return Depends(_negotiate_dep)

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:

  • :func:settings_options — one documented, self-propagating click.Option per settings 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.
  • :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.
  • :func:default_log_config — a complete dictConfig that survives worker spawn.

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)

default_log_config

default_log_config(
    level: str = 'INFO',
    *,
    json_logs: bool = False,
    request_id: bool = False,
) -> dict[str, Any]

A complete dictConfig so a server'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; these are lazy string references resolved by logging.config at config time, not imports here — but they do assume uvicorn is installed when the config is applied. The json_logs mode uses only :class:JsonFormatter and has no uvicorn dependency at all.

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.context.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/cli.py
def default_log_config(
    level: str = 'INFO',
    *,
    json_logs: bool = False,
    request_id: bool = False,
) -> dict[str, Any]:
    """A complete dictConfig so a server'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; these are *lazy string references*
    resolved by ``logging.config`` at config time, not imports here — but they do assume
    uvicorn is installed when the config is applied. The ``json_logs`` mode uses only
    :class:`JsonFormatter` and has no uvicorn dependency at all.

    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.context.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': {'()': f'{__name__}.JsonFormatter'},
            'access': {'()': f'{__name__}.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.context.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},
    }

settings_options

settings_options(
    settings_cls: type[BaseSettings],
    *,
    exclude: Collection[str] = (),
    rename: Mapping[str, str] | None = None,
) -> list[click.Parameter]

One self-documenting, self-propagating click.Option per non-secret field.

Each option is prefixed by the class's (required) env_prefix (e.g. --app-host) so it namespaces cleanly against the server's own options and other settings groups, carries its env var for --help, and — via a callback — writes that env var when passed, so the value reaches the app with no export step of your own. Options are expose_value=False: they act purely by side effect, so they don't appear in the command callback's signature.

Compose the lists from several classes to expose more than one group on one command ([*settings_options(A), *settings_options(B)]); each class needs a distinct env_prefix. This is the presentation half of serve_command, exposed so a custom CLI can attach these options to its own command.

Parameters:

Name Type Description Default
settings_cls type[BaseSettings]

The pydantic-settings class to document.

required
exclude Collection[str]

Field names to omit. Use this to drop a field you pin to a constant — set its env var yourself (or leave the app's default to stand) instead.

()
rename Mapping[str, str] | None

{field_name: decl} to give a field a different flag, e.g. {'greeting': '--message'}, to unify names across a larger CLI. The env var is unchanged, so the renamed option still propagates to the same field. A renamed bool becomes the usual toggle automatically ('--x' -> --x/--no-x); give the full '--x/--no-x' form to control both names.

None

No type gating: an option just writes a string to the env var, and pydantic deserializes it exactly as it does for env loading — so an option can carry whatever an env var can (scalars directly; complex types as a JSON string). Secret fields (SecretStr/SecretBytes) get no option, so a secret never lands on the command line.

Source code in src/gazebo/ext/cli.py
def settings_options(
    settings_cls: type[BaseSettings],
    *,
    exclude: Collection[str] = (),
    rename: Mapping[str, str] | None = None,
) -> list[click.Parameter]:
    """One self-documenting, self-propagating ``click.Option`` per non-secret field.

    Each option is prefixed by the class's (required) ``env_prefix`` (e.g.
    ``--app-host``) so it namespaces cleanly against the server's own options and other
    settings groups, carries its env var for ``--help``, and — via a callback — writes
    that env var when passed, so the value reaches the app with no export step of your
    own. Options are ``expose_value=False``: they act purely by side effect, so they
    don't appear in the command callback's signature.

    Compose the lists from several classes to expose more than one group on one command
    (``[*settings_options(A), *settings_options(B)]``); each class needs a distinct
    ``env_prefix``. This is the presentation half of ``serve_command``, exposed so a
    custom CLI can attach these options to its own command.

    Args:
        settings_cls: The ``pydantic-settings`` class to document.
        exclude: Field names to omit. Use this to drop a field you pin to a constant —
            set its env var yourself (or leave the app's default to stand) instead.
        rename: ``{field_name: decl}`` to give a field a different flag, e.g.
            ``{'greeting': '--message'}``, to unify names across a larger CLI. The env
            var is unchanged, so the renamed option still propagates to the same field.
            A renamed ``bool`` becomes the usual toggle automatically (``'--x'`` ->
            ``--x/--no-x``); give the full ``'--x/--no-x'`` form to control both names.

    No type gating: an option just writes a string to the env var, and pydantic
    deserializes it exactly as it does for env loading — so an option can carry whatever
    an env var can (scalars directly; complex types as a JSON string). Secret fields
    (``SecretStr``/``SecretBytes``) get no option, so a secret never lands on the
    command line."""
    _require_env_prefix(settings_cls)
    rename = rename or {}
    opts: list[click.Parameter] = []
    prefix = settings_cls.model_config.get('env_prefix', '')
    case_sensitive = settings_cls.model_config.get('case_sensitive', False)
    group = prefix.rstrip('_').lower()
    for name, field in settings_cls.model_fields.items():
        if name in exclude:
            continue
        anno = field.annotation
        if _is_secret(anno):
            continue  # never put a secret on the command line
        anno = _unwrap_optional(anno)
        envvar = prefix + name
        envvar = envvar if case_sensitive else envvar.upper()
        dest = f'{group}_{name}' if group else name
        dash = dest.replace('_', '-')
        default = field.default if field.default is not PydanticUndefined else None
        required = field.is_required()
        kw: dict[str, Any] = {
            'envvar': envvar,
            'show_envvar': True,
            'show_default': True,
            'help': field.description,
            # the option acts by side effect only (write env var on the command line);
            # nothing downstream needs its value, so keep it out of the callback kwargs.
            'expose_value': False,
            'callback': _propagate_to_env,
            # click reads `envvar`, so a required field is satisfied by the option OR
            # the env var (not forced onto the command line) — shown in --help, errors
            # early only if neither is set. A required option must carry NO default: a
            # default (even None) suppresses click's required check.
            'required': required,
        }
        if not required:
            kw['default'] = default
        if isinstance(anno, type) and issubclass(anno, Enum):
            kw['type'] = click.Choice([e.value for e in anno])
            if isinstance(default, Enum):
                kw['default'] = default.value
        # Every field gets an option (no type gating). The default is a plain string
        # option: the value is written verbatim to the env var and pydantic deserializes
        # it exactly as for env loading — scalars (str/int/Path/UUID/...) directly,
        # complex types (list/dict/model) as JSON. Only bool and enum get a special
        # widget, for UX and self-documentation — not because of how they parse.
        if name in rename:
            decl = rename[name]
            if anno is bool and '/' not in decl:
                decl = f'{decl}/--no-{decl.lstrip("-")}'  # same toggle a bool always gets
            decls = [decl]
        elif anno is bool:
            decls = [f'--{dash}/--no-{dash}']
        else:
            decls = [f'--{dash}']
        opts.append(click.Option(decls, **kw))
    return opts

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 :func:gazebo.ext.cli.settings_options), 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.

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.

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:gazebo.ext.cli.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:`gazebo.ext.cli.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: type[BaseSettings]
    | Sequence[type[BaseSettings]]
    | 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 type[BaseSettings] | Sequence[type[BaseSettings]] | None

A pydantic-settings class or a sequence of them. Each becomes a self-documenting option group, namespaced by its (required, distinct) env_prefix; a passed option sets the matching env var. See :func:gazebo.ext.cli.settings_options to compose these onto a command of your own.

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:gazebo.ext.cli.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: type[BaseSettings] | Sequence[type[BaseSettings]] | 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: A pydantic-settings class or a sequence of them. Each becomes a
            self-documenting option group, namespaced by its (required, distinct)
            ``env_prefix``; a passed option sets the matching env var. See
            :func:`gazebo.ext.cli.settings_options` to compose these onto a command of
            your own.
        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:`gazebo.ext.cli.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

    classes = (settings,) if isinstance(settings, type) else tuple(settings or ())
    seen_prefixes: set[str] = set()
    setting_opts: list[click.Parameter] = []
    for cls in classes:
        # settings_options() enforces a non-empty env_prefix; here we additionally
        # require it be distinct across groups so their flags/env vars can't collide.
        opts = settings_options(cls)
        prefix = cls.model_config.get('env_prefix', '')
        if prefix in seen_prefixes:
            raise ValueError(
                f'duplicate env_prefix {prefix!r} across settings groups; '
                'each group needs a distinct prefix',
            )
        seen_prefixes.add(prefix)
        setting_opts.extend(opts)

    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 = secrets_epilog(classes) if classes else None
    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.',
    )