Skip to content

Commit 4fe9339

Browse files
authored
Support wiring of Cython-compiled modules (#965)
1 parent dc3bd65 commit 4fe9339

8 files changed

Lines changed: 218 additions & 4 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ src/**/*.h
7070
src/**/*.so
7171
src/**/*.html
7272

73+
# Cython test fixture build outputs
74+
tests/unit/samples/wiringcython/*.c
75+
tests/unit/samples/wiringcython/*.so
76+
tests/unit/samples/wiringcython/_build/
77+
7378
# Workspace for samples
7479
.workspace/
7580

docs/wiring.rst

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,57 @@ or with a single container ``register_loader_containers(container)`` multiple ti
658658
To unregister a container use ``unregister_loader_containers(container)``.
659659
Wiring module will uninstall the import hook when unregister last container.
660660

661+
Wiring of Cython-compiled modules
662+
---------------------------------
663+
664+
Modules compiled with Cython (e.g. to ship business logic as ``.so``
665+
extensions in source-protected container images) are wired transparently
666+
provided the compile sets two directives:
667+
668+
* ``binding=True`` — preserve descriptor / bound-method semantics so
669+
:func:`inspect.signature` and the wiring discovery pass work as they do
670+
for pure-Python functions.
671+
* ``embedsignature=True`` — embed the Python-style signature so
672+
:func:`inspect.signature` can recover parameter names, annotations, and
673+
``Provide[...]`` / ``Provider[...]`` markers from the compiled function.
674+
675+
A typical ``cythonize`` invocation that produces wiring-compatible
676+
extensions for a FastAPI / dependency-injector codebase:
677+
678+
.. code-block:: python
679+
680+
from Cython.Build import cythonize
681+
682+
cythonize(
683+
["my_package/handlers/*.py"],
684+
compiler_directives={
685+
"language_level": 3,
686+
"binding": True,
687+
"embedsignature": True,
688+
},
689+
)
690+
691+
FastAPI views and dependencies that rely on parameter defaults as
692+
markers (``param: str = Header(...)``, ``svc: Service = Depends(...)``,
693+
``Provide[Container.x]``) need Cython's C-level annotation typing
694+
disabled. The default in Cython 3.x is ``annotation_typing=True``, which
695+
generates ``isinstance`` checks against the annotated types and rejects
696+
the marker objects at call time. Opt out per-function:
697+
698+
.. code-block:: python
699+
700+
import cython
701+
702+
@cython.annotation_typing(False)
703+
async def list_users(
704+
svc: UserService = Depends(Provide[Container.user_service]),
705+
) -> list[User]:
706+
return await svc.list()
707+
708+
Apply the decorator to every FastAPI view or dependency callable that
709+
takes a marker-style default. Module-level ``annotation_typing=False``
710+
works too if the whole module is FastAPI-bound.
711+
661712
Few notes on performance
662713
------------------------
663714

src/dependency_injector/wiring.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ def is_werkzeug_local_proxy(obj: Any) -> bool:
127127

128128
INSPECT_EXCLUSION_FILTERS.append(is_werkzeug_local_proxy)
129129

130+
131+
def _is_cyfunction(obj: Any) -> bool:
132+
"""Return True for Cython-compiled functions/methods (non-fused)."""
133+
return type(obj).__name__ == "cython_function_or_method"
134+
135+
136+
def _is_function_like(obj: Any) -> bool:
137+
"""Return True for pure-Python functions and Cython-compiled functions."""
138+
return isfunction(obj) or _is_cyfunction(obj)
139+
140+
130141
from . import providers # noqa: E402
131142

132143
__all__ = (
@@ -485,7 +496,7 @@ def wire( # noqa: C901
485496
warn_unresolved=warn_unresolved,
486497
warn_unresolved_stacklevel=1,
487498
)
488-
elif isfunction(member):
499+
elif _is_function_like(member):
489500
_patch_fn(
490501
module,
491502
member_name,
@@ -548,10 +559,10 @@ def unwire( # noqa: C901
548559

549560
for module in modules:
550561
for name, member in getmembers(module):
551-
if isfunction(member):
562+
if _is_function_like(member):
552563
_unpatch(module, name, member)
553564
elif isclass(member):
554-
for method_name, method in getmembers(member, isfunction):
565+
for method_name, method in getmembers(member, _is_function_like):
555566
_unpatch(member, method_name, method)
556567

557568
for patched in _patched_registry.get_callables_from_module(module):
@@ -803,7 +814,7 @@ def _fetch_modules(package):
803814

804815

805816
def _is_method(member) -> bool:
806-
return ismethod(member) or isfunction(member)
817+
return ismethod(member) or _is_function_like(member)
807818

808819

809820
def _is_marker(member) -> bool:

tests/unit/samples/wiringcython/__init__.py

Whitespace-only changes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""DI container used by the Cython-compiled wiring fixture."""
2+
3+
from dependency_injector import containers, providers
4+
5+
6+
class Service:
7+
"""Simple service injected into the Cython-compiled fixture handlers."""
8+
9+
def __init__(self, value: str = "default") -> None:
10+
self.value = value
11+
12+
async def aget(self) -> str:
13+
return self.value
14+
15+
def get(self) -> str:
16+
return self.value
17+
18+
19+
class Container(containers.DeclarativeContainer):
20+
service = providers.Factory(Service, value="injected")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# cython: language_level=3, binding=True, embedsignature=True, annotation_typing=False
2+
3+
from dependency_injector.wiring import Provide
4+
5+
from samples.wiringcython.container import Container, Service
6+
7+
8+
def sync_handler(svc: Service = Provide[Container.service]) -> str:
9+
return svc.get()
10+
11+
12+
async def async_handler(svc: Service = Provide[Container.service]) -> str:
13+
return await svc.aget()
14+
15+
16+
async def async_gen_handler(svc: Service = Provide[Container.service]):
17+
yield svc.get()
18+
yield svc.get() + "_2"
19+
20+
21+
class HandlerClass:
22+
async def __call__(self, svc: Service = Provide[Container.service]) -> str:
23+
return svc.get()

tests/unit/wiring/test_cython.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Wiring discovery against Cython-compiled user modules."""
2+
3+
import pytest
4+
5+
pytest.importorskip("Cython")
6+
7+
import pyximport # noqa: E402
8+
9+
pyximport.install(language_level=3)
10+
11+
cythonmodule = pytest.importorskip(
12+
"samples.wiringcython.cythonmodule",
13+
reason="Cython fixture not built (Cython / C toolchain missing)",
14+
)
15+
16+
from samples.wiringcython.container import Container, Service # noqa: E402
17+
18+
from dependency_injector import providers # noqa: E402
19+
from dependency_injector.wiring import ( # noqa: E402
20+
_is_cyfunction,
21+
_is_function_like,
22+
_patched_registry,
23+
)
24+
25+
26+
@pytest.fixture
27+
def container():
28+
c = Container()
29+
c.wire(modules=[cythonmodule])
30+
yield c
31+
c.unwire()
32+
33+
34+
def _pure_python_fn():
35+
pass
36+
37+
38+
@pytest.mark.parametrize(
39+
"obj,is_cy,is_func_like",
40+
[
41+
pytest.param(lambda: cythonmodule.sync_handler, True, True, id="cython-sync"),
42+
pytest.param(lambda: cythonmodule.async_handler, True, True, id="cython-async"),
43+
pytest.param(
44+
lambda: cythonmodule.async_gen_handler, True, True, id="cython-async-gen"
45+
),
46+
pytest.param(
47+
lambda: cythonmodule.HandlerClass.__call__,
48+
True,
49+
True,
50+
id="cython-class-call",
51+
),
52+
pytest.param(lambda: _pure_python_fn, False, True, id="pure-python"),
53+
],
54+
)
55+
def test_function_like_predicate(obj, is_cy, is_func_like):
56+
target = obj()
57+
assert _is_cyfunction(target) is is_cy
58+
assert _is_function_like(target) is is_func_like
59+
60+
61+
def test_sync_handler_wired(container):
62+
assert cythonmodule.sync_handler() == "injected"
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_async_handler_wired(container):
67+
assert await cythonmodule.async_handler() == "injected"
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_async_gen_handler_wired(container):
72+
results = [v async for v in cythonmodule.async_gen_handler()]
73+
assert results == ["injected", "injected_2"]
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_class_method_wired(container):
78+
handler = cythonmodule.HandlerClass()
79+
assert await handler() == "injected"
80+
81+
82+
def test_sync_handler_respects_provider_override(container):
83+
with container.service.override(providers.Object(Service(value="overridden"))):
84+
assert cythonmodule.sync_handler() == "overridden"
85+
assert cythonmodule.sync_handler() == "injected"
86+
87+
88+
def test_unwire_clears_injection_bindings_on_compiled_module():
89+
c = Container()
90+
c.wire(modules=[cythonmodule])
91+
92+
wrapper = cythonmodule.sync_handler
93+
patched = _patched_registry.get_callable(wrapper)
94+
95+
assert patched is not None
96+
assert patched.reference_injections
97+
assert patched.injections
98+
99+
c.unwire()
100+
101+
assert patched.injections == {}
102+
assert patched.reference_injections

tox.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ deps=
1818
pydantic-settings
1919
werkzeug
2020
fast-depends
21+
# Cython is required to build tests/unit/samples/wiringcython/
22+
cython>=3,<4
2123
extras=
2224
yaml
2325
commands = pytest

0 commit comments

Comments
 (0)