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
42 changes: 42 additions & 0 deletions docs/wiring.rst
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,48 @@ 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,
# Keep annotation_typing=False for FastAPI handlers using
# `param: str = Header(...)` / `dep: Service = Depends(...)`:
# with annotation_typing=True (the Cython 3.x default!) Cython
# generates a C-level isinstance check against the default
# sentinel and raises `TypeError: Expected str, got Header` at
# import time.
"annotation_typing": False,
Comment thread
keyz182 marked this conversation as resolved.
Outdated
},
)

No public API change in *Dependency Injector* is required to consume
compiled modules — ``container.wire(packages=[my_package])`` /
``container.wire(modules=[my_compiled_module])`` discover and patch
cyfunctions alongside pure-Python functions in the same package tree.

Comment thread
keyz182 marked this conversation as resolved.
Outdated
Few notes on performance
------------------------

Expand Down
86 changes: 80 additions & 6 deletions src/dependency_injector/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from functools import wraps
from importlib import import_module, invalidate_caches as invalidate_import_caches
from inspect import (
CO_ASYNC_GENERATOR,
CO_COROUTINE,
Parameter,
getmembers,
isasyncgenfunction,
Expand Down Expand Up @@ -127,6 +129,78 @@ 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.

Cython-compiled callables (built with ``binding=True`` and
``embedsignature=True``) are not recognised by :func:`inspect.isfunction`
because they are instances of ``cython_function_or_method`` rather than
``types.FunctionType``. They are nevertheless safe targets for wiring:
dep-injector only *reads* ``inspect.signature`` (which works on
cyfunctions with ``embedsignature=True``) and wraps the original via
``functools.wraps``; no writes are performed on ``__code__``,
``__defaults__`` or ``__globals__``.

Recognises ``cython_function_or_method`` only. Fused-function templates
(``fused_cython_function``) dispatch per call and are intentionally
excluded — wrapping the template would inject before type dispatch,
which has not been validated. Fused support is potential follow-up
work.

Cython >= 3.1.0 is the tested floor. Earlier versions may work but
the ``co_flags`` fallbacks in :func:`_iscoroutinefunction_compat` and
:func:`_isasyncgenfunction_compat` exist specifically because
Cython < 3.0 did not surface coroutine / async-generator status via
:mod:`inspect`.
"""
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.

Wiring's discovery pass must accept both so that codebases compiled to
``.so`` extensions (e.g. for source-protected container images) can be
wired transparently.
"""
return isfunction(obj) or _is_cyfunction(obj)


def _iscoroutinefunction_compat(fn: Any) -> bool:
Comment thread
ZipFile marked this conversation as resolved.
Outdated
"""Coroutine-function check that also handles Cython-compiled ``async def``.

Cython 3.x exposes coroutine cyfunctions correctly via
:func:`inspect.iscoroutinefunction`. Cython < 3.0 did not — for those
versions the underlying ``__code__.co_flags`` still carries the
``CO_COROUTINE`` bit, so fall back to that.
"""
if iscoroutinefunction(fn):
return True
code = getattr(fn, "__code__", None)
if code is None:
return False
return bool(getattr(code, "co_flags", 0) & CO_COROUTINE)


def _isasyncgenfunction_compat(fn: Any) -> bool:
"""Async-generator check that also handles Cython-compiled ``async def`` w/ yield.

Symmetric to :func:`_iscoroutinefunction_compat`: Cython < 3.0
Comment thread
ZipFile marked this conversation as resolved.
Outdated
async-generator cyfunctions are not recognised by
:func:`inspect.isasyncgenfunction`, but the ``CO_ASYNC_GENERATOR`` bit
is still present in ``__code__.co_flags``. Without this helper, async-
gen cyfunctions would fall through to ``_get_sync_patched`` and break
at first ``await`` / ``async for``.
"""
if isasyncgenfunction(fn):
return True
code = getattr(fn, "__code__", None)
if code is None:
return False
return bool(getattr(code, "co_flags", 0) & CO_ASYNC_GENERATOR)
Comment thread
ZipFile marked this conversation as resolved.
Outdated


from . import providers # noqa: E402

__all__ = (
Expand Down Expand Up @@ -485,7 +559,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 +622,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 +877,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 All @@ -821,9 +895,9 @@ def _get_patched(
reference_closing=reference_closing,
)

if iscoroutinefunction(fn):
if _iscoroutinefunction_compat(fn):
patched = _get_async_patched(fn, patched_object)
elif isasyncgenfunction(fn):
elif _isasyncgenfunction_compat(fn):
patched = _get_async_gen_patched(fn, patched_object)
else:
patched = _get_sync_patched(fn, patched_object)
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")
44 changes: 44 additions & 0 deletions tests/unit/samples/wiringcython/cythonmodule.pyx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# cython: language_level=3, binding=True, embedsignature=True, annotation_typing=False
"""Cython-compiled fixture exercising wire() against compiled handlers.

Compiled by tests/unit/wiring/conftest.py::pytest_configure with the four
directives FastAPI / dependency-injector codebases require:

binding=True — preserve descriptor semantics (so inspect.signature
+ DI introspection work)
embedsignature=True — embed Python-style signature for inspect.signature
annotation_typing=False — annotations stay informational; do NOT generate
C-level isinstance checks against parameter defaults
(the FastAPI `param: str = Header(...)` pattern
relies on this)
language_level=3 — pure Python 3 semantics

Exercises every call shape the wiring discovery pass dispatches on:

- sync def at module level -> _get_sync_patched
- async def at module level -> _get_async_patched
- async def with yield (async generator) -> _get_async_gen_patched
- class with async def __call__ -> _patch_method
"""
Comment thread
keyz182 marked this conversation as resolved.
Outdated

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()
111 changes: 111 additions & 0 deletions tests/unit/wiring/conftest.py
Comment thread
ZipFile marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Build Cython test fixtures at session start.

The wiring test suite includes regression coverage for Cython-compiled user
modules (see tests/unit/wiring/test_cython.py). This conftest compiles the
.pyx fixture into a .so before test collection so the import succeeds.

If Cython or a C toolchain is unavailable, the build is skipped silently
and the cython test module raises pytest.importorskip at collection.
"""

import logging
import sysconfig
from pathlib import Path

_LOG = logging.getLogger(__name__)

_FIXTURE_DIR = (
Path(__file__).resolve().parent.parent / "samples" / "wiringcython"
)
_PYX = _FIXTURE_DIR / "cythonmodule.pyx"


def _ext_suffix() -> str:
return sysconfig.get_config_var("EXT_SUFFIX") or ".so"
Comment thread
ZipFile marked this conversation as resolved.
Outdated


def _fixture_so_path() -> Path:
"""Exact .so path the build produces under the current interpreter.

Includes the ABI tag (e.g. ``.cpython-313-x86_64-linux-gnu.so``) so a
stray .so built against a different Python / platform is treated as a
miss instead of skipping the build silently on cross-ABI CI runs.
"""
return _FIXTURE_DIR / f"cythonmodule{_ext_suffix()}"


def _fixture_already_built() -> bool:
so_path = _fixture_so_path()
if not so_path.exists():
return False
return so_path.stat().st_mtime >= _PYX.stat().st_mtime


def _build_fixture() -> bool:
if not _PYX.exists():
return False
if _fixture_already_built():
return True
try:
from Cython.Build import cythonize
from setuptools import Extension
from setuptools.command.build_ext import build_ext
from setuptools.dist import Distribution
except ImportError as exc:
Comment thread
ZipFile marked this conversation as resolved.
Outdated
_LOG.info("Cython fixture build skipped (missing dep): %s", exc)
return False

# Bare module name (no dots) means setuptools writes the .so directly
# to ``<build_lib>/cythonmodule<EXT_SUFFIX>`` — no package-layout
# subdir is created. With ``build_lib`` pointed at the fixture dir,
# the .so lands next to the .pyx where
# ``from samples.wiringcython.cythonmodule import ...`` resolves via
# the ``tests/unit/conftest.py`` ``sys.path`` insertion.
#
# ``inplace`` MUST be 0 here: ``inplace=1`` ignores ``build_lib`` and
# writes next to the source tree relative to CWD, which on a clean
# checkout drops the .so at the repo root.
ext = Extension(
"cythonmodule",
sources=[str(_PYX)],
)
ext_modules = cythonize(
[ext],
compiler_directives={
"language_level": 3,
"binding": True,
"embedsignature": True,
"annotation_typing": False,
},
quiet=True,
)
dist = Distribution(
{"name": "wiringcython_fixture", "ext_modules": ext_modules}
)
cmd = build_ext(dist)
cmd.inplace = 0
cmd.build_lib = str(_FIXTURE_DIR)
cmd.build_temp = str(_FIXTURE_DIR / "_build")
cmd.ensure_finalized()
try:
cmd.run()
except Exception as exc: # noqa: BLE001 — surface every build failure mode
_LOG.warning("Cython fixture build failed: %s", exc)
return False

# Positive post-condition — the .so must be where the test importer
# will look. If setuptools silently changed layout under us, fail
# loud here instead of letting test_cython.py skip on importorskip.
so_path = _fixture_so_path()
if not so_path.exists():
_LOG.warning(
"Cython fixture build completed but expected .so missing at %s",
so_path,
)
return False
return True


def pytest_configure(config):
"""Compile cythonmodule.pyx so test_cython.py can import the .so."""
_build_fixture()
Loading