diff --git a/.gitignore b/.gitignore index 86ecf7c7..2a25c1e7 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/wiring.rst b/docs/wiring.rst index 3d5778c4..e0cbed5f 100644 --- a/docs/wiring.rst +++ b/docs/wiring.rst @@ -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 +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 ------------------------ diff --git a/src/dependency_injector/wiring.py b/src/dependency_injector/wiring.py index b4d78f70..62267c47 100644 --- a/src/dependency_injector/wiring.py +++ b/src/dependency_injector/wiring.py @@ -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__ = ( @@ -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, @@ -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): @@ -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: diff --git a/tests/unit/samples/wiringcython/__init__.py b/tests/unit/samples/wiringcython/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/samples/wiringcython/container.py b/tests/unit/samples/wiringcython/container.py new file mode 100644 index 00000000..ba414bc9 --- /dev/null +++ b/tests/unit/samples/wiringcython/container.py @@ -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") diff --git a/tests/unit/samples/wiringcython/cythonmodule.pyx b/tests/unit/samples/wiringcython/cythonmodule.pyx new file mode 100644 index 00000000..4642dd49 --- /dev/null +++ b/tests/unit/samples/wiringcython/cythonmodule.pyx @@ -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() diff --git a/tests/unit/wiring/test_cython.py b/tests/unit/wiring/test_cython.py new file mode 100644 index 00000000..80a9a42a --- /dev/null +++ b/tests/unit/wiring/test_cython.py @@ -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 diff --git a/tox.ini b/tox.ini index 2ad3ca2a..8ba6a6d5 100644 --- a/tox.ini +++ b/tox.ini @@ -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