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)
- 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
...
- Make
OffsetPagination a dataclass or msgspec.Struct — both preserve runtime-visible fields/annotations even after C-extension compilation. Minimal source change, broadest fix.
- 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.
Summary
sqlspec.core.filters.OffsetPaginationhas empty__annotations__at runtime (== {}), which causes Litestar's OpenAPI schema generator to emit an empty"schema": {}for any handler that returnsOffsetPagination[T]. The inner generic typeTis never registered as an OpenAPI component, so downstream TypeScript client generators (e.g.,@hey-api/openapi-ts) can't produce a type forTunless it's also referenced as a body parameter elsewhere on the API surface.Root cause
sqlspec/core/filters.pyships a compiled C extension alongside the.pysource:The Python source has correct annotations:
But Python imports the compiled
.sopreferentially, and class-level annotations are stripped in the compiled module:Contrast with Litestar's own pagination type, which is pure Python and works correctly:
Reproducer
Expected:
Itemregistered as an OpenAPI component, response schema declares{items: Item[], limit, offset, total}.Actual:
Itemis 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 importingMigrationEffortItemfrom the generatedtypes.gen.tsbroke 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(containingobjects: list[MigrationEffortItem]). After removal, 17 handlers returningOffsetPagination[MigrationEffortItem]registered nothing,types.gen.tslost the type, and two frontend files failedtsc.Proposed fixes (in order of increasing preference)
OpenAPISchemaPlugininsqlspec.extensions.litestar.SQLSpecPluginthat expandsOffsetPagination[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:OffsetPaginationadataclassormsgspec.Struct— both preserve runtime-visible fields/annotations even after C-extension compilation. Minimal source change, broadest fix.filters.pyfrom 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
OffsetPaginationis defined and would also benefit anyone usingOffsetPaginationdirectly as a body/response type outside Litestar.Environment
sqlspec==0.43.0litestar==2.21.1Workaround currently in use
In DMA we declared a concrete wrapper struct that mirrors
OffsetPagination's wire shape:…used as the handler return type instead of
OffsetPagination[MigrationEffortItem]. Service layer convertsOffsetPagination-> wrapper on return. Functional but clearly boilerplate that belongs upstream.Happy to send a PR if one of the fix shapes is acceptable.