Skip to content

OffsetPagination has empty runtime __annotations__ — Litestar OpenAPI schema generator produces empty schema for paginated responses #419

@cofin

Description

@cofin

Summary

sqlspec.core.filters.OffsetPagination has empty __annotations__ at runtime (== {}), which causes Litestar's OpenAPI schema generator to emit an empty "schema": {} for any handler that returns OffsetPagination[T]. The inner generic type T is never registered as an OpenAPI component, so downstream TypeScript client generators (e.g., @hey-api/openapi-ts) can't produce a type for T unless it's also referenced as a body parameter elsewhere on the API surface.

Root cause

sqlspec/core/filters.py ships a compiled C extension alongside the .py source:

$ ls .venv/lib/python3.12/site-packages/sqlspec/core/filters*
sqlspec/core/filters.cpython-312-x86_64-linux-gnu.so
sqlspec/core/filters.py

The Python source has correct annotations:

class OffsetPagination(Generic[T]):
    __slots__ = ("items", "limit", "offset", "total")
    items: Sequence[T]
    limit: int
    offset: int
    total: int

But Python imports the compiled .so preferentially, and class-level annotations are stripped in the compiled module:

>>> from sqlspec.core.filters import OffsetPagination
>>> OffsetPagination.__annotations__
{}
>>> import typing
>>> typing.get_type_hints(OffsetPagination)
{}

Contrast with Litestar's own pagination type, which is pure Python and works correctly:

>>> from litestar.pagination import OffsetPagination as LitestarOP
>>> LitestarOP.__annotations__
{'items': 'List[T]', 'limit': 'int', 'offset': 'int', 'total': 'int'}

Reproducer

from litestar import Litestar, get
from litestar.testing import create_test_client
import msgspec
from sqlspec.core.filters import OffsetPagination


class Item(msgspec.Struct):
    name: str


@get("/items")
async def list_items() -> OffsetPagination[Item]:
    return OffsetPagination(items=[Item(name="a")], limit=10, offset=0, total=1)


app = Litestar(route_handlers=[list_items])
schema = app.openapi_schema
print("Item in components:", "Item" in schema.components.schemas)  # False
paths = schema.paths["/items"]
get_op = paths.get
response_200 = get_op.responses["200"]
print("response schema:", response_200.content["application/json"].schema_)  # empty

Expected: Item registered as an OpenAPI component, response schema declares {items: Item[], limit, offset, total}.
Actual: Item is absent from components; response schema is empty.

Impact

Any downstream tool that generates clients from openapi.json (@hey-api/openapi-ts, openapi-typescript-codegen, openapi-generator) can't produce a type for the paginated item when pagination is the ONLY place the type appears. In our case, a frontend file importing MigrationEffortItem from the generated types.gen.ts broke when we removed the only body-parameter reference to the type — every pagination-return-type path was silently invisible to the generator.

Seen in the wild: removed a sync endpoint whose body param was ExportData (containing objects: list[MigrationEffortItem]). After removal, 17 handlers returning OffsetPagination[MigrationEffortItem] registered nothing, types.gen.ts lost the type, and two frontend files failed tsc.

Proposed fixes (in order of increasing preference)

  1. Register an OpenAPISchemaPlugin in sqlspec.extensions.litestar.SQLSpecPlugin that expands OffsetPagination[T] into a concrete schema shape {items: list[T], limit: int, offset: int, total: int} at schema-generation time. Plugin-only workaround, no change to the filter class. Example skeleton:
    class OffsetPaginationSchemaPlugin(OpenAPISchemaPlugin):
        @staticmethod
        def is_plugin_supported_type(value: Any) -> bool:
            return isinstance(value, type) and issubclass(value, OffsetPagination)
        def to_openapi_schema(self, field_definition, schema_creator):
            # introspect generic parameter, build {items, limit, offset, total} schema
            ...
  2. Make OffsetPagination a dataclass or msgspec.Struct — both preserve runtime-visible fields/annotations even after C-extension compilation. Minimal source change, broadest fix.
  3. Exclude filters.py from the Cython/mypyc build — preserves runtime annotations. Simplest but loses the (presumably intentional) perf benefit of compilation for the filter module.

(1) is a drop-in additive change. (2) aligns with how Litestar's own OffsetPagination is defined and would also benefit anyone using OffsetPagination directly as a body/response type outside Litestar.

Environment

  • sqlspec==0.43.0
  • litestar==2.21.1
  • Python 3.12.3
  • Linux x86_64

Workaround currently in use

In DMA we declared a concrete wrapper struct that mirrors OffsetPagination's wire shape:

class MigrationEffortItemPage(CamelizedBaseStruct, kw_only=True):
    items: list[MigrationEffortItem]
    limit: int
    offset: int
    total: int

…used as the handler return type instead of OffsetPagination[MigrationEffortItem]. Service layer converts OffsetPagination -> wrapper on return. Functional but clearly boilerplate that belongs upstream.

Happy to send a PR if one of the fix shapes is acceptable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions