Skip to content
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ src/**/*.h
src/**/*.so
src/**/*.html

# Cython test fixture build outputs
tests/unit/samples/wiringcython/*.c
tests/unit/samples/wiringcython/*.so
tests/unit/samples/wiringcython/_build/

# Workspace for samples
.workspace/

Expand Down
51 changes: 51 additions & 0 deletions docs/wiring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,57 @@ or with a single container ``register_loader_containers(container)`` multiple ti
To unregister a container use ``unregister_loader_containers(container)``.
Wiring module will uninstall the import hook when unregister last container.

Wiring of Cython-compiled modules
---------------------------------

Modules compiled with Cython (e.g. to ship business logic as ``.so``
extensions in source-protected container images) are wired transparently
provided the compile sets two directives:

* ``binding=True`` — preserve descriptor / bound-method semantics so
:func:`inspect.signature` and the wiring discovery pass work as they do
for pure-Python functions.
* ``embedsignature=True`` — embed the Python-style signature so
:func:`inspect.signature` can recover parameter names, annotations, and
``Provide[...]`` / ``Provider[...]`` markers from the compiled function.

A typical ``cythonize`` invocation that produces wiring-compatible
extensions for a FastAPI / dependency-injector codebase:

.. code-block:: python

from Cython.Build import cythonize

cythonize(
["my_package/handlers/*.py"],
compiler_directives={
"language_level": 3,
"binding": True,
"embedsignature": True,
},
)

FastAPI views and dependencies that rely on parameter defaults as
Comment thread
ZipFile marked this conversation as resolved.
markers (``param: str = Header(...)``, ``svc: Service = Depends(...)``,
``Provide[Container.x]``) need Cython's C-level annotation typing
disabled. The default in Cython 3.x is ``annotation_typing=True``, which
generates ``isinstance`` checks against the annotated types and rejects
the marker objects at call time. Opt out per-function:

.. code-block:: python

import cython

@cython.annotation_typing(False)
async def list_users(
svc: UserService = Depends(Provide[Container.user_service]),
) -> list[User]:
return await svc.list()

Apply the decorator to every FastAPI view or dependency callable that
takes a marker-style default. Module-level ``annotation_typing=False``
works too if the whole module is FastAPI-bound.

Few notes on performance
------------------------

Expand Down
19 changes: 15 additions & 4 deletions src/dependency_injector/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ def is_werkzeug_local_proxy(obj: Any) -> bool:

INSPECT_EXCLUSION_FILTERS.append(is_werkzeug_local_proxy)


def _is_cyfunction(obj: Any) -> bool:
"""Return True for Cython-compiled functions/methods (non-fused)."""
return type(obj).__name__ == "cython_function_or_method"


def _is_function_like(obj: Any) -> bool:
"""Return True for pure-Python functions and Cython-compiled functions."""
return isfunction(obj) or _is_cyfunction(obj)


from . import providers # noqa: E402

__all__ = (
Expand Down Expand Up @@ -485,7 +496,7 @@ def wire( # noqa: C901
warn_unresolved=warn_unresolved,
warn_unresolved_stacklevel=1,
)
elif isfunction(member):
elif _is_function_like(member):
_patch_fn(
module,
member_name,
Expand Down Expand Up @@ -548,10 +559,10 @@ def unwire( # noqa: C901

for module in modules:
for name, member in getmembers(module):
if isfunction(member):
if _is_function_like(member):
_unpatch(module, name, member)
elif isclass(member):
for method_name, method in getmembers(member, isfunction):
for method_name, method in getmembers(member, _is_function_like):
_unpatch(member, method_name, method)

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


def _is_method(member) -> bool:
return ismethod(member) or isfunction(member)
return ismethod(member) or _is_function_like(member)


def _is_marker(member) -> bool:
Expand Down
Empty file.
20 changes: 20 additions & 0 deletions tests/unit/samples/wiringcython/container.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""DI container used by the Cython-compiled wiring fixture."""

from dependency_injector import containers, providers


class Service:
"""Simple service injected into the Cython-compiled fixture handlers."""

def __init__(self, value: str = "default") -> None:
self.value = value

async def aget(self) -> str:
return self.value

def get(self) -> str:
return self.value


class Container(containers.DeclarativeContainer):
service = providers.Factory(Service, value="injected")
23 changes: 23 additions & 0 deletions tests/unit/samples/wiringcython/cythonmodule.pyx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# cython: language_level=3, binding=True, embedsignature=True, annotation_typing=False

from dependency_injector.wiring import Provide

from samples.wiringcython.container import Container, Service


def sync_handler(svc: Service = Provide[Container.service]) -> str:
return svc.get()


async def async_handler(svc: Service = Provide[Container.service]) -> str:
return await svc.aget()


async def async_gen_handler(svc: Service = Provide[Container.service]):
yield svc.get()
yield svc.get() + "_2"


class HandlerClass:
async def __call__(self, svc: Service = Provide[Container.service]) -> str:
return svc.get()
102 changes: 102 additions & 0 deletions tests/unit/wiring/test_cython.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Wiring discovery against Cython-compiled user modules."""

import pytest

pytest.importorskip("Cython")

import pyximport # noqa: E402

pyximport.install(language_level=3)

cythonmodule = pytest.importorskip(
"samples.wiringcython.cythonmodule",
reason="Cython fixture not built (Cython / C toolchain missing)",
)

from samples.wiringcython.container import Container, Service # noqa: E402

from dependency_injector import providers # noqa: E402
from dependency_injector.wiring import ( # noqa: E402
_is_cyfunction,
_is_function_like,
_patched_registry,
)


@pytest.fixture
def container():
c = Container()
c.wire(modules=[cythonmodule])
yield c
c.unwire()


def _pure_python_fn():
pass


@pytest.mark.parametrize(
"obj,is_cy,is_func_like",
[
pytest.param(lambda: cythonmodule.sync_handler, True, True, id="cython-sync"),
pytest.param(lambda: cythonmodule.async_handler, True, True, id="cython-async"),
pytest.param(
lambda: cythonmodule.async_gen_handler, True, True, id="cython-async-gen"
),
pytest.param(
lambda: cythonmodule.HandlerClass.__call__,
True,
True,
id="cython-class-call",
),
pytest.param(lambda: _pure_python_fn, False, True, id="pure-python"),
],
)
def test_function_like_predicate(obj, is_cy, is_func_like):
target = obj()
assert _is_cyfunction(target) is is_cy
assert _is_function_like(target) is is_func_like


def test_sync_handler_wired(container):
assert cythonmodule.sync_handler() == "injected"


@pytest.mark.asyncio
async def test_async_handler_wired(container):
assert await cythonmodule.async_handler() == "injected"


@pytest.mark.asyncio
async def test_async_gen_handler_wired(container):
results = [v async for v in cythonmodule.async_gen_handler()]
assert results == ["injected", "injected_2"]


@pytest.mark.asyncio
async def test_class_method_wired(container):
handler = cythonmodule.HandlerClass()
assert await handler() == "injected"


def test_sync_handler_respects_provider_override(container):
with container.service.override(providers.Object(Service(value="overridden"))):
assert cythonmodule.sync_handler() == "overridden"
assert cythonmodule.sync_handler() == "injected"


def test_unwire_clears_injection_bindings_on_compiled_module():
c = Container()
c.wire(modules=[cythonmodule])

wrapper = cythonmodule.sync_handler
patched = _patched_registry.get_callable(wrapper)

assert patched is not None
assert patched.reference_injections
assert patched.injections

c.unwire()

assert patched.injections == {}
assert patched.reference_injections
2 changes: 2 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ deps=
pydantic-settings
werkzeug
fast-depends
# Cython is required to build tests/unit/samples/wiringcython/
cython>=3,<4
extras=
yaml
commands = pytest
Expand Down