Skip to content

Commit 098b920

Browse files
committed
Support wiring of Cython-compiled modules
The wiring discovery pass gated on inspect.isfunction / ismethod, which return False for cython_function_or_method, silently skipping handlers compiled to .so. Recognise cyfunctions in discovery and dispatch, fall back to __code__.co_flags for async-status detection on Cython < 3. Adds samples/wiringcython/ fixture (sync, async, async-gen, class __call__) + 15 regression tests. Cython added to [testenv] deps so the default tox invocation builds the fixture.
1 parent fcb3ea3 commit 098b920

9 files changed

Lines changed: 546 additions & 6 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,48 @@ 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+
# Keep annotation_typing=False for FastAPI handlers using
689+
# `param: str = Header(...)` / `dep: Service = Depends(...)`:
690+
# with annotation_typing=True (the Cython 3.x default!) Cython
691+
# generates a C-level isinstance check against the default
692+
# sentinel and raises `TypeError: Expected str, got Header` at
693+
# import time.
694+
"annotation_typing": False,
695+
},
696+
)
697+
698+
No public API change in *Dependency Injector* is required to consume
699+
compiled modules — ``container.wire(packages=[my_package])`` /
700+
``container.wire(modules=[my_compiled_module])`` discover and patch
701+
cyfunctions alongside pure-Python functions in the same package tree.
702+
661703
Few notes on performance
662704
------------------------
663705

src/dependency_injector/wiring.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from functools import wraps
77
from importlib import import_module, invalidate_caches as invalidate_import_caches
88
from inspect import (
9+
CO_ASYNC_GENERATOR,
10+
CO_COROUTINE,
911
Parameter,
1012
getmembers,
1113
isasyncgenfunction,
@@ -127,6 +129,78 @@ def is_werkzeug_local_proxy(obj: Any) -> bool:
127129

128130
INSPECT_EXCLUSION_FILTERS.append(is_werkzeug_local_proxy)
129131

132+
133+
def _is_cyfunction(obj: Any) -> bool:
134+
"""Return True for Cython-compiled functions/methods.
135+
136+
Cython-compiled callables (built with ``binding=True`` and
137+
``embedsignature=True``) are not recognised by :func:`inspect.isfunction`
138+
because they are instances of ``cython_function_or_method`` rather than
139+
``types.FunctionType``. They are nevertheless safe targets for wiring:
140+
dep-injector only *reads* ``inspect.signature`` (which works on
141+
cyfunctions with ``embedsignature=True``) and wraps the original via
142+
``functools.wraps``; no writes are performed on ``__code__``,
143+
``__defaults__`` or ``__globals__``.
144+
145+
Recognises ``cython_function_or_method`` only. Fused-function templates
146+
(``fused_cython_function``) dispatch per call and are intentionally
147+
excluded — wrapping the template would inject before type dispatch,
148+
which has not been validated. Fused support is potential follow-up
149+
work.
150+
151+
Cython >= 3.1.0 is the tested floor. Earlier versions may work but
152+
the ``co_flags`` fallbacks in :func:`_iscoroutinefunction_compat` and
153+
:func:`_isasyncgenfunction_compat` exist specifically because
154+
Cython < 3.0 did not surface coroutine / async-generator status via
155+
:mod:`inspect`.
156+
"""
157+
return type(obj).__name__ == "cython_function_or_method"
158+
159+
160+
def _is_function_like(obj: Any) -> bool:
161+
"""Return True for pure-Python functions and Cython-compiled functions.
162+
163+
Wiring's discovery pass must accept both so that codebases compiled to
164+
``.so`` extensions (e.g. for source-protected container images) can be
165+
wired transparently.
166+
"""
167+
return isfunction(obj) or _is_cyfunction(obj)
168+
169+
170+
def _iscoroutinefunction_compat(fn: Any) -> bool:
171+
"""Coroutine-function check that also handles Cython-compiled ``async def``.
172+
173+
Cython 3.x exposes coroutine cyfunctions correctly via
174+
:func:`inspect.iscoroutinefunction`. Cython < 3.0 did not — for those
175+
versions the underlying ``__code__.co_flags`` still carries the
176+
``CO_COROUTINE`` bit, so fall back to that.
177+
"""
178+
if iscoroutinefunction(fn):
179+
return True
180+
code = getattr(fn, "__code__", None)
181+
if code is None:
182+
return False
183+
return bool(getattr(code, "co_flags", 0) & CO_COROUTINE)
184+
185+
186+
def _isasyncgenfunction_compat(fn: Any) -> bool:
187+
"""Async-generator check that also handles Cython-compiled ``async def`` w/ yield.
188+
189+
Symmetric to :func:`_iscoroutinefunction_compat`: Cython < 3.0
190+
async-generator cyfunctions are not recognised by
191+
:func:`inspect.isasyncgenfunction`, but the ``CO_ASYNC_GENERATOR`` bit
192+
is still present in ``__code__.co_flags``. Without this helper, async-
193+
gen cyfunctions would fall through to ``_get_sync_patched`` and break
194+
at first ``await`` / ``async for``.
195+
"""
196+
if isasyncgenfunction(fn):
197+
return True
198+
code = getattr(fn, "__code__", None)
199+
if code is None:
200+
return False
201+
return bool(getattr(code, "co_flags", 0) & CO_ASYNC_GENERATOR)
202+
203+
130204
from . import providers # noqa: E402
131205

132206
__all__ = (
@@ -485,7 +559,7 @@ def wire( # noqa: C901
485559
warn_unresolved=warn_unresolved,
486560
warn_unresolved_stacklevel=1,
487561
)
488-
elif isfunction(member):
562+
elif _is_function_like(member):
489563
_patch_fn(
490564
module,
491565
member_name,
@@ -548,10 +622,10 @@ def unwire( # noqa: C901
548622

549623
for module in modules:
550624
for name, member in getmembers(module):
551-
if isfunction(member):
625+
if _is_function_like(member):
552626
_unpatch(module, name, member)
553627
elif isclass(member):
554-
for method_name, method in getmembers(member, isfunction):
628+
for method_name, method in getmembers(member, _is_function_like):
555629
_unpatch(member, method_name, method)
556630

557631
for patched in _patched_registry.get_callables_from_module(module):
@@ -803,7 +877,7 @@ def _fetch_modules(package):
803877

804878

805879
def _is_method(member) -> bool:
806-
return ismethod(member) or isfunction(member)
880+
return ismethod(member) or _is_function_like(member)
807881

808882

809883
def _is_marker(member) -> bool:
@@ -821,9 +895,9 @@ def _get_patched(
821895
reference_closing=reference_closing,
822896
)
823897

824-
if iscoroutinefunction(fn):
898+
if _iscoroutinefunction_compat(fn):
825899
patched = _get_async_patched(fn, patched_object)
826-
elif isasyncgenfunction(fn):
900+
elif _isasyncgenfunction_compat(fn):
827901
patched = _get_async_gen_patched(fn, patched_object)
828902
else:
829903
patched = _get_sync_patched(fn, patched_object)

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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# cython: language_level=3, binding=True, embedsignature=True, annotation_typing=False
2+
"""Cython-compiled fixture exercising wire() against compiled handlers.
3+
4+
Compiled by tests/unit/wiring/conftest.py::pytest_configure with the four
5+
directives FastAPI / dependency-injector codebases require:
6+
7+
binding=True — preserve descriptor semantics (so inspect.signature
8+
+ DI introspection work)
9+
embedsignature=True — embed Python-style signature for inspect.signature
10+
annotation_typing=False — annotations stay informational; do NOT generate
11+
C-level isinstance checks against parameter defaults
12+
(the FastAPI `param: str = Header(...)` pattern
13+
relies on this)
14+
language_level=3 — pure Python 3 semantics
15+
16+
Exercises every call shape the wiring discovery pass dispatches on:
17+
18+
- sync def at module level -> _get_sync_patched
19+
- async def at module level -> _get_async_patched
20+
- async def with yield (async generator) -> _get_async_gen_patched
21+
- class with async def __call__ -> _patch_method
22+
"""
23+
24+
from dependency_injector.wiring import Provide
25+
26+
from samples.wiringcython.container import Container, Service
27+
28+
29+
def sync_handler(svc: Service = Provide[Container.service]) -> str:
30+
return svc.get()
31+
32+
33+
async def async_handler(svc: Service = Provide[Container.service]) -> str:
34+
return await svc.aget()
35+
36+
37+
async def async_gen_handler(svc: Service = Provide[Container.service]):
38+
yield svc.get()
39+
yield svc.get() + "_2"
40+
41+
42+
class HandlerClass:
43+
async def __call__(self, svc: Service = Provide[Container.service]) -> str:
44+
return svc.get()

tests/unit/wiring/conftest.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Build Cython test fixtures at session start.
2+
3+
The wiring test suite includes regression coverage for Cython-compiled user
4+
modules (see tests/unit/wiring/test_cython.py). This conftest compiles the
5+
.pyx fixture into a .so before test collection so the import succeeds.
6+
7+
If Cython or a C toolchain is unavailable, the build is skipped silently
8+
and the cython test module raises pytest.importorskip at collection.
9+
"""
10+
11+
import logging
12+
import sysconfig
13+
from pathlib import Path
14+
15+
_LOG = logging.getLogger(__name__)
16+
17+
_FIXTURE_DIR = (
18+
Path(__file__).resolve().parent.parent / "samples" / "wiringcython"
19+
)
20+
_PYX = _FIXTURE_DIR / "cythonmodule.pyx"
21+
22+
23+
def _ext_suffix() -> str:
24+
return sysconfig.get_config_var("EXT_SUFFIX") or ".so"
25+
26+
27+
def _fixture_so_path() -> Path:
28+
"""Exact .so path the build produces under the current interpreter.
29+
30+
Includes the ABI tag (e.g. ``.cpython-313-x86_64-linux-gnu.so``) so a
31+
stray .so built against a different Python / platform is treated as a
32+
miss instead of skipping the build silently on cross-ABI CI runs.
33+
"""
34+
return _FIXTURE_DIR / f"cythonmodule{_ext_suffix()}"
35+
36+
37+
def _fixture_already_built() -> bool:
38+
so_path = _fixture_so_path()
39+
if not so_path.exists():
40+
return False
41+
return so_path.stat().st_mtime >= _PYX.stat().st_mtime
42+
43+
44+
def _build_fixture() -> bool:
45+
if not _PYX.exists():
46+
return False
47+
if _fixture_already_built():
48+
return True
49+
try:
50+
from Cython.Build import cythonize
51+
from setuptools import Extension
52+
from setuptools.command.build_ext import build_ext
53+
from setuptools.dist import Distribution
54+
except ImportError as exc:
55+
_LOG.info("Cython fixture build skipped (missing dep): %s", exc)
56+
return False
57+
58+
# Bare module name (no dots) means setuptools writes the .so directly
59+
# to ``<build_lib>/cythonmodule<EXT_SUFFIX>`` — no package-layout
60+
# subdir is created. With ``build_lib`` pointed at the fixture dir,
61+
# the .so lands next to the .pyx where
62+
# ``from samples.wiringcython.cythonmodule import ...`` resolves via
63+
# the ``tests/unit/conftest.py`` ``sys.path`` insertion.
64+
#
65+
# ``inplace`` MUST be 0 here: ``inplace=1`` ignores ``build_lib`` and
66+
# writes next to the source tree relative to CWD, which on a clean
67+
# checkout drops the .so at the repo root.
68+
ext = Extension(
69+
"cythonmodule",
70+
sources=[str(_PYX)],
71+
)
72+
ext_modules = cythonize(
73+
[ext],
74+
compiler_directives={
75+
"language_level": 3,
76+
"binding": True,
77+
"embedsignature": True,
78+
"annotation_typing": False,
79+
},
80+
quiet=True,
81+
)
82+
dist = Distribution(
83+
{"name": "wiringcython_fixture", "ext_modules": ext_modules}
84+
)
85+
cmd = build_ext(dist)
86+
cmd.inplace = 0
87+
cmd.build_lib = str(_FIXTURE_DIR)
88+
cmd.build_temp = str(_FIXTURE_DIR / "_build")
89+
cmd.ensure_finalized()
90+
try:
91+
cmd.run()
92+
except Exception as exc: # noqa: BLE001 — surface every build failure mode
93+
_LOG.warning("Cython fixture build failed: %s", exc)
94+
return False
95+
96+
# Positive post-condition — the .so must be where the test importer
97+
# will look. If setuptools silently changed layout under us, fail
98+
# loud here instead of letting test_cython.py skip on importorskip.
99+
so_path = _fixture_so_path()
100+
if not so_path.exists():
101+
_LOG.warning(
102+
"Cython fixture build completed but expected .so missing at %s",
103+
so_path,
104+
)
105+
return False
106+
return True
107+
108+
109+
def pytest_configure(config):
110+
"""Compile cythonmodule.pyx so test_cython.py can import the .so."""
111+
_build_fixture()

0 commit comments

Comments
 (0)