Skip to content

Commit 7272c50

Browse files
authored
fix(extensions): drop filter-provider modules from mypyc to restore deepcopy (0.48.2) (#481)
## Summary - Drops `sqlspec/extensions/litestar/providers.py` and `sqlspec/extensions/fastapi/providers.py` from the mypyc compile set so route-handler deepcopy stops crashing in Litestar's `Controller.get_route_handlers()`. - Adds discovery-based deepcopy + pickle unit tests for every `_*FilterProvider` class in both extension modules. - Adds integration tests reproducing the user's `Controller.get_route_handlers()` stack trace plus the pre-registration `copy.deepcopy(handler)` path. - Bumps version to **0.48.2** for patch release. ## The bug Starting in 0.48.1, `sqlspec/extensions/{litestar,fastapi}/providers.py` shipped as mypyc-compiled `.so` extensions. Every `_*FilterProvider` runtime context class in those modules has an `__init__` with required positional args. Litestar's `Controller.get_route_handlers()` performs `copy.deepcopy(self_handler)` per route at registration time, and `copy`'s default reconstruction calls `cls.__new__(cls)`. Under mypyc, `__new__` chains into `__init__()` (with no args) — which raises: ``` TypeError: __init__() missing required argument 'default_page_size' (pos 1) ``` Every Litestar Controller wiring `create_filter_dependencies(...)` trips this. DMA's `make js-api-update` (the original reporter) crashes during OpenAPI schema generation. ## Why the in-place fix was abandoned The first attempt followed PR #480's pattern (`__reduce__` + `_reconstruction_args`). The second attempt (Option G — default-arg `__init__` relying on mypyc's auto-generated `__getstate__` / `__setstate__` over `__mypyc_attrs__`) failed because: 1. mypyc strict-typing rejected the tuple default for the `SortField` parameter on `_OrderByProvider` (the union doesn't include tuple). 2. A separate `cannot pickle 'module' object` issue surfaced further down the handler chain, requiring additional workarounds for an issue unrelated to providers. These provider classes precompute `inspect.Signature` once per `FilterConfig` and are cached by `dep_cache` for the life of the process — the mypyc win is amortized to app boot only. Net-cost of the source-side workarounds exceeded the perf benefit, so we opted out instead. ## Tests - `tests/unit/extensions/test_litestar/test_providers_deepcopy.py` and `tests/unit/extensions/test_fastapi/test_providers_deepcopy.py` — discovery-based parametrized deepcopy + pickle round-trips. New `_*FilterProvider` classes auto-join coverage via `inspect.getmembers`-style filtering. - `tests/unit/extensions/test_litestar/test_controller_deepcopy.py` — integration tests covering: (a) `copy.deepcopy(handler)` on a `@get(..., dependencies=create_filter_dependencies(...))` decorated handler, (b) idempotent re-deepcopy, (c) the user's exact `Controller.get_route_handlers()` stack-trace path via a `_ProbeController` instance. All tests reproduce the documented `TypeError` against the buggy build and pass after the fix. ## Trade-off Standing project rule "never reduce the mypyc compile set" overridden for these two specific modules after empirical evaluation. The remaining ~80 modules continue to be mypyc-compiled. Rationale captured in the bd epic notes (`sqlspec-nzkc`) and the archived Flow spec.
1 parent c75649c commit 7272c50

7 files changed

Lines changed: 480 additions & 25 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
2424
name = "sqlspec"
2525
readme = "README.md"
2626
requires-python = ">=3.10, <4.0"
27-
version = "0.48.1"
27+
version = "0.48.2"
2828

2929
[project.urls]
3030
Discord = "https://discord.gg/litestar"
@@ -193,7 +193,6 @@ packages = ["sqlspec"]
193193
packages = []
194194

195195

196-
197196
[tool.hatch.build.targets.wheel.hooks.mypyc]
198197
dependencies = ["hatch-mypyc", "hatch-cython", "mypy>=2.0.0"]
199198
enable-by-default = false
@@ -242,8 +241,6 @@ include = [
242241
"sqlspec/storage/pipeline.py", # Storage bridge orchestration with Arrow boundary split out
243242
"sqlspec/storage/backends/base.py", # Storage backend runtime base classes
244243
"sqlspec/data_dictionary/**/*.py", # Data dictionary registry, loader, and dialect helpers
245-
"sqlspec/extensions/fastapi/providers.py", # FastAPI filter providers avoid nested dependency functions
246-
"sqlspec/extensions/litestar/providers.py", # Litestar filter providers precompute dynamic signatures
247244
"sqlspec/extensions/events/_hints.py", # Adapter event runtime defaults
248245
"sqlspec/extensions/events/_payload.py", # Event JSON payload codec helpers
249246
"sqlspec/extensions/adk/_types.py", # ADK session/event record types
@@ -293,7 +290,7 @@ opt_level = "3" # Maximum optimization (0-3)
293290
allow_dirty = true
294291
commit = false
295292
commit_args = "--no-verify"
296-
current_version = "0.48.1"
293+
current_version = "0.48.2"
297294
ignore_missing_files = false
298295
ignore_missing_version = false
299296
message = "chore(release): bump to v{new_version}"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Round-trip tests for copy.deepcopy and pickle on mypyc-compiled FastAPI filter providers."""
2+
3+
# Safe: pickle round-trips here serialize objects produced inside the test and
4+
# immediately deserialize them in the same process. No untrusted input crosses
5+
# the pickle boundary.
6+
import copy
7+
import inspect
8+
import pickle
9+
from typing import Any
10+
11+
import pytest
12+
13+
from sqlspec.extensions.fastapi import providers as fastapi_providers
14+
from sqlspec.extensions.fastapi.providers import FieldNameType
15+
16+
_FACTORIES: "dict[str, Any]" = {
17+
"_LimitOffsetFilterProvider": lambda cls: cls(20),
18+
"_IdFilterProvider": lambda cls: cls("id", int),
19+
"_BeforeAfterFilterProvider": lambda cls: cls("created_at", "createdBefore", "createdAfter"),
20+
"_SearchFilterProvider": lambda cls: cls({"name", "email"}, False),
21+
"_OrderByProvider": lambda cls: cls("id", {"sort_field": "id"}),
22+
"_CollectionFilterProvider": lambda cls: cls(FieldNameType("status", str), negated=False),
23+
"_NullFilterProvider": lambda cls: cls("deleted_at", negated=False),
24+
}
25+
26+
27+
def _discover_provider_classes() -> "list[type[Any]]":
28+
classes: list[type[Any]] = []
29+
for name, cls in inspect.getmembers(fastapi_providers, inspect.isclass):
30+
if not name.startswith("_") or name.startswith("__"):
31+
continue
32+
if not name.endswith("Provider"):
33+
continue
34+
if cls.__module__ != fastapi_providers.__name__:
35+
continue
36+
classes.append(cls)
37+
return classes
38+
39+
40+
def _state_attrs(inst: Any) -> "tuple[str, ...]":
41+
mypyc_attrs = getattr(type(inst), "__mypyc_attrs__", None)
42+
if mypyc_attrs is not None:
43+
return tuple(mypyc_attrs)
44+
return tuple(k for k in vars(inst) if not k.startswith("__"))
45+
46+
47+
def _build(cls: "type[Any]") -> Any:
48+
factory = _FACTORIES.get(cls.__name__)
49+
if factory is None:
50+
msg = f"No factory registered for {cls.__name__}. Add an entry to _FACTORIES at the top of this test module."
51+
raise AssertionError(msg)
52+
return factory(cls)
53+
54+
55+
def _assert_state_equal(left: Any, right: Any) -> None:
56+
for attr in _state_attrs(left):
57+
left_value = getattr(left, attr)
58+
right_value = getattr(right, attr)
59+
assert left_value == right_value, (
60+
f"{type(left).__name__}.{attr} diverged after round-trip: {left_value!r} != {right_value!r}"
61+
)
62+
63+
64+
@pytest.mark.parametrize("cls", _discover_provider_classes(), ids=lambda c: c.__name__)
65+
def test_provider_deepcopy_roundtrip(cls: "type[Any]") -> None:
66+
inst = _build(cls)
67+
out = copy.deepcopy(inst)
68+
assert out is not inst
69+
assert type(out) is type(inst)
70+
_assert_state_equal(inst, out)
71+
72+
73+
@pytest.mark.parametrize("cls", _discover_provider_classes(), ids=lambda c: c.__name__)
74+
def test_provider_pickle_roundtrip(cls: "type[Any]") -> None:
75+
inst = _build(cls)
76+
out = pickle.loads(pickle.dumps(inst))
77+
assert out is not inst
78+
assert type(out) is type(inst)
79+
_assert_state_equal(inst, out)
80+
81+
82+
def test_factory_registry_covers_all_discovered_classes() -> None:
83+
discovered = {cls.__name__ for cls in _discover_provider_classes()}
84+
registered = set(_FACTORIES)
85+
missing = discovered - registered
86+
extra = registered - discovered
87+
assert not missing, f"_FACTORIES missing entries for newly added provider classes: {missing}"
88+
assert not extra, f"_FACTORIES has stale entries for removed/renamed classes: {extra}"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Integration test for the route-handler-deepcopy crash reported against 0.48.1.
2+
3+
Litestar's `Controller.get_route_handlers()` performs `copy.deepcopy(self_handler)`
4+
at controller registration time -- *before* the app attaches the `.app` / `.owner`
5+
back-pointer to the handler. Pre-fix, that deepcopy walked into
6+
`handler.dependencies` -> `Provide` -> `functools.partial(provide_fn, _*Provider)`
7+
and crashed on the mypyc-compiled provider's `__new__` -> `__init__()` chain
8+
with `TypeError: __init__() missing required argument`.
9+
10+
These tests exercise that same pre-registration deepcopy via the public Litestar
11+
API: `@get(..., dependencies=...)` returns a `BaseRouteHandler` whose
12+
`dependencies` dict already contains the filter `Provide` objects, and
13+
`copy.deepcopy(handler)` reproduces the user's exact stack trace path.
14+
"""
15+
16+
import copy
17+
from typing import Any
18+
19+
import pytest
20+
from litestar import Controller, get
21+
from litestar.router import Router
22+
23+
from sqlspec.extensions.litestar.providers import FieldNameType, create_filter_dependencies, dep_cache
24+
from sqlspec.typing import LITESTAR_INSTALLED
25+
26+
if not LITESTAR_INSTALLED:
27+
pytest.skip("Litestar not installed", allow_module_level=True)
28+
29+
30+
@pytest.fixture(autouse=True)
31+
def _clear_dependency_cache() -> Any:
32+
dep_cache.dependencies.clear()
33+
yield
34+
dep_cache.dependencies.clear()
35+
36+
37+
def _dep_keys(handler: Any) -> "set[str]":
38+
return set(getattr(handler, "dependencies", None) or ())
39+
40+
41+
def test_handler_deepcopy_with_limit_offset_filter() -> None:
42+
"""Minimum repro: a handler with a single LimitOffsetFilter dep must deepcopy."""
43+
deps = create_filter_dependencies({"pagination_type": "limit_offset", "pagination_size": 10})
44+
45+
@get("/probe", dependencies=deps)
46+
async def probe() -> "list[Any]":
47+
return []
48+
49+
out = copy.deepcopy(probe)
50+
assert out is not probe
51+
assert _dep_keys(out) == _dep_keys(probe)
52+
53+
54+
def test_handler_deepcopy_with_full_filter_set() -> None:
55+
"""Every supported filter type wired in. Each filter `Provide` must survive deepcopy."""
56+
deps = create_filter_dependencies({
57+
"id_filter": int,
58+
"id_field": "id",
59+
"pagination_type": "limit_offset",
60+
"pagination_size": 25,
61+
"search": "name",
62+
"search_ignore_case": False,
63+
"sort_field": "id",
64+
"created_at": True,
65+
"updated_at": True,
66+
"in_fields": [FieldNameType("name", str)],
67+
"not_in_fields": [FieldNameType("name", str)],
68+
"null_fields": "deleted_at",
69+
"not_null_fields": "created_at",
70+
})
71+
72+
@get("/probe", dependencies=deps)
73+
async def probe() -> "list[Any]":
74+
return []
75+
76+
out = copy.deepcopy(probe)
77+
assert out is not probe
78+
assert _dep_keys(out) == _dep_keys(probe)
79+
80+
81+
def test_handler_repeated_deepcopy_is_idempotent() -> None:
82+
"""A deepcopied handler must itself deepcopy."""
83+
deps = create_filter_dependencies({"pagination_type": "limit_offset"})
84+
85+
@get("/probe", dependencies=deps)
86+
async def probe() -> "list[Any]":
87+
return []
88+
89+
first = copy.deepcopy(probe)
90+
second = copy.deepcopy(first)
91+
assert second is not first
92+
assert _dep_keys(second) == _dep_keys(first)
93+
94+
95+
def test_controller_get_route_handlers_with_filter_dependencies() -> None:
96+
"""Reproduce the user's exact Controller.get_route_handlers() stack trace path.
97+
98+
`Controller.get_route_handlers()` is what crashed in the user's report. It
99+
calls `copy.deepcopy(self_handler)` on each handler attribute of the
100+
Controller class. To force the controller's handlers to carry the filter
101+
`Provide` objects at the point of that deepcopy, we attach `dependencies` to
102+
the `@get` decorator (Litestar 2.22's Controller-level `dependencies` are
103+
merged at request time, not at `get_route_handlers` time).
104+
"""
105+
deps = create_filter_dependencies({"pagination_type": "limit_offset", "search": "name", "sort_field": "id"})
106+
107+
class _ProbeController(Controller):
108+
path = "/probe"
109+
110+
@get("/", dependencies=deps)
111+
async def list_items(self) -> "list[Any]":
112+
return []
113+
114+
owner = Router(path="/api", route_handlers=[])
115+
handlers = list(_ProbeController(owner=owner).get_route_handlers())
116+
117+
assert len(handlers) == 1
118+
assert _dep_keys(handlers[0]) == set(deps)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Round-trip tests for copy.deepcopy and pickle on mypyc-compiled filter providers."""
2+
3+
# Safe: pickle round-trips here serialize objects produced inside the test and
4+
# immediately deserialize them in the same process. No untrusted input crosses
5+
# the pickle boundary.
6+
import copy
7+
import inspect
8+
import pickle
9+
from typing import Any
10+
11+
import pytest
12+
13+
from sqlspec.extensions.litestar import providers as litestar_providers
14+
from sqlspec.extensions.litestar.providers import FieldNameType
15+
from sqlspec.typing import LITESTAR_INSTALLED
16+
17+
if not LITESTAR_INSTALLED:
18+
pytest.skip("Litestar not installed", allow_module_level=True)
19+
20+
21+
_FACTORIES: "dict[str, Any]" = {
22+
"_LimitOffsetFilterProvider": lambda cls: cls(20),
23+
"_IdFilterProvider": lambda cls: cls("id", int),
24+
"_BeforeAfterFilterProvider": lambda cls: cls("created_at", "createdBefore", "createdAfter"),
25+
"_SearchFilterProvider": lambda cls: cls({"name", "email"}, False),
26+
"_OrderByProvider": lambda cls: cls("id", {"sort_field": "id"}),
27+
"_CollectionFilterProvider": lambda cls: cls(FieldNameType("status", str), negated=False),
28+
"_NullFilterProvider": lambda cls: cls("deleted_at", negated=False),
29+
}
30+
31+
32+
def _discover_provider_classes() -> "list[type[Any]]":
33+
classes: list[type[Any]] = []
34+
for name, cls in inspect.getmembers(litestar_providers, inspect.isclass):
35+
if not name.startswith("_") or name.startswith("__"):
36+
continue
37+
if not name.endswith("Provider"):
38+
continue
39+
if cls.__module__ != litestar_providers.__name__:
40+
continue
41+
classes.append(cls)
42+
return classes
43+
44+
45+
def _state_attrs(inst: Any) -> "tuple[str, ...]":
46+
mypyc_attrs = getattr(type(inst), "__mypyc_attrs__", None)
47+
if mypyc_attrs is not None:
48+
return tuple(mypyc_attrs)
49+
return tuple(k for k in vars(inst) if not k.startswith("__"))
50+
51+
52+
def _build(cls: "type[Any]") -> Any:
53+
factory = _FACTORIES.get(cls.__name__)
54+
if factory is None:
55+
msg = f"No factory registered for {cls.__name__}. Add an entry to _FACTORIES at the top of this test module."
56+
raise AssertionError(msg)
57+
return factory(cls)
58+
59+
60+
def _assert_state_equal(left: Any, right: Any) -> None:
61+
for attr in _state_attrs(left):
62+
left_value = getattr(left, attr)
63+
right_value = getattr(right, attr)
64+
assert left_value == right_value, (
65+
f"{type(left).__name__}.{attr} diverged after round-trip: {left_value!r} != {right_value!r}"
66+
)
67+
68+
69+
@pytest.mark.parametrize("cls", _discover_provider_classes(), ids=lambda c: c.__name__)
70+
def test_provider_deepcopy_roundtrip(cls: "type[Any]") -> None:
71+
inst = _build(cls)
72+
out = copy.deepcopy(inst)
73+
assert out is not inst
74+
assert type(out) is type(inst)
75+
_assert_state_equal(inst, out)
76+
77+
78+
@pytest.mark.parametrize("cls", _discover_provider_classes(), ids=lambda c: c.__name__)
79+
def test_provider_pickle_roundtrip(cls: "type[Any]") -> None:
80+
inst = _build(cls)
81+
out = pickle.loads(pickle.dumps(inst))
82+
assert out is not inst
83+
assert type(out) is type(inst)
84+
_assert_state_equal(inst, out)
85+
86+
87+
def test_factory_registry_covers_all_discovered_classes() -> None:
88+
discovered = {cls.__name__ for cls in _discover_provider_classes()}
89+
registered = set(_FACTORIES)
90+
missing = discovered - registered
91+
extra = registered - discovered
92+
assert not missing, f"_FACTORIES missing entries for newly added provider classes: {missing}"
93+
assert not extra, f"_FACTORIES has stale entries for removed/renamed classes: {extra}"

tests/unit/utils/test_mypyc_inventory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ def test_inventory_records_rest_of_mypyc_boundary_decisions() -> None:
141141
assert "sqlspec/extensions/events/_models.py" in payload["interpreted_modules"]
142142
assert "sqlspec/extensions/events/_queue.py" in payload["interpreted_modules"]
143143
assert "sqlspec/extensions/adk/converters.py" in payload["interpreted_modules"]
144-
assert "sqlspec/extensions/fastapi/providers.py" in payload["compiled_modules"]
145-
assert "sqlspec/extensions/litestar/providers.py" in payload["compiled_modules"]
144+
assert "sqlspec/extensions/fastapi/providers.py" in payload["interpreted_modules"]
145+
assert "sqlspec/extensions/litestar/providers.py" in payload["interpreted_modules"]
146146
assert "sqlspec/storage/_arrow_payload.py" in payload["interpreted_modules"]
147147
assert "sqlspec/observability/_formatting.py" in payload["interpreted_modules"]
148148
assert "sqlspec/extensions/events/_models.py" in payload["preserved_exclusions"]

tools/scripts/mypyc_inventory.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,12 @@
173173
"reason": "Imports Google ADK models at module import time and keeps Pydantic model reconstruction interpreted.",
174174
},
175175
"sqlspec/extensions/fastapi/providers.py": {
176-
"classification": "compile_now",
177-
"reason": "Framework provider surface now uses module-level callable providers with precomputed dynamic signatures.",
176+
"classification": "keep_interpreted",
177+
"reason": "Compiled providers crash on copy.deepcopy via mypyc native cls.__new__ -> __init__() chain; providers precompute signatures once per FilterConfig and are cached in dep_cache, so the mypyc win is boot-only.",
178178
},
179179
"sqlspec/extensions/litestar/providers.py": {
180-
"classification": "compile_now",
181-
"reason": "Framework provider surface now uses module-level callable providers with precomputed dynamic signatures.",
180+
"classification": "keep_interpreted",
181+
"reason": "Compiled providers crash on copy.deepcopy via mypyc native cls.__new__ -> __init__() chain; providers precompute signatures once per FilterConfig and are cached in dep_cache, so the mypyc win is boot-only.",
182182
},
183183
"sqlspec/migrations/commands.py": {
184184
"classification": "keep_interpreted",

0 commit comments

Comments
 (0)