From 098b920df908e2434a829145b54e7a7d9f55a12f Mon Sep 17 00:00:00 2001 From: Kieran David Evans Date: Mon, 18 May 2026 18:24:59 +0100 Subject: [PATCH 01/10] 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. --- .gitignore | 5 + docs/wiring.rst | 42 +++ src/dependency_injector/wiring.py | 86 ++++++- tests/unit/samples/wiringcython/__init__.py | 0 tests/unit/samples/wiringcython/container.py | 20 ++ .../samples/wiringcython/cythonmodule.pyx | 44 ++++ tests/unit/wiring/conftest.py | 111 ++++++++ tests/unit/wiring/test_cython.py | 239 ++++++++++++++++++ tox.ini | 5 + 9 files changed, 546 insertions(+), 6 deletions(-) create mode 100644 tests/unit/samples/wiringcython/__init__.py create mode 100644 tests/unit/samples/wiringcython/container.py create mode 100644 tests/unit/samples/wiringcython/cythonmodule.pyx create mode 100644 tests/unit/wiring/conftest.py create mode 100644 tests/unit/wiring/test_cython.py diff --git a/.gitignore b/.gitignore index 86ecf7c73..2a25c1e76 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 3d5778c41..e692a35f1 100644 --- a/docs/wiring.rst +++ b/docs/wiring.rst @@ -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, + }, + ) + +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. + Few notes on performance ------------------------ diff --git a/src/dependency_injector/wiring.py b/src/dependency_injector/wiring.py index b4d78f70c..fb0adb1f7 100644 --- a/src/dependency_injector/wiring.py +++ b/src/dependency_injector/wiring.py @@ -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, @@ -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: + """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 + 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) + + from . import providers # noqa: E402 __all__ = ( @@ -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, @@ -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): @@ -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: @@ -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) diff --git a/tests/unit/samples/wiringcython/__init__.py b/tests/unit/samples/wiringcython/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/samples/wiringcython/container.py b/tests/unit/samples/wiringcython/container.py new file mode 100644 index 000000000..ba414bc98 --- /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 000000000..43f4558e2 --- /dev/null +++ b/tests/unit/samples/wiringcython/cythonmodule.pyx @@ -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 +""" + +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/conftest.py b/tests/unit/wiring/conftest.py new file mode 100644 index 000000000..4d0c8afcd --- /dev/null +++ b/tests/unit/wiring/conftest.py @@ -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" + + +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: + _LOG.info("Cython fixture build skipped (missing dep): %s", exc) + return False + + # Bare module name (no dots) means setuptools writes the .so directly + # to ``/cythonmodule`` — 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() diff --git a/tests/unit/wiring/test_cython.py b/tests/unit/wiring/test_cython.py new file mode 100644 index 000000000..21afd49f2 --- /dev/null +++ b/tests/unit/wiring/test_cython.py @@ -0,0 +1,239 @@ +"""Regression coverage: wiring discovery against Cython-compiled user modules. + +Builds the .pyx fixture in conftest.py::pytest_configure, then asserts every +callable shape the wire() dispatch handles is correctly recognised AND +runtime-injected when compiled to a .so: + + - module-level sync ``def`` + - module-level ``async def`` + - module-level async-generator ``async def`` with ``yield`` + - class with async ``def __call__`` + +Also asserts the cyfunction detection helpers behave correctly on both +compiled and pure-Python callables, exercises the ``CO_COROUTINE`` / +``CO_ASYNC_GENERATOR`` fallback branches via a synthetic code object, +and pins the descriptor type for class-attribute ``__call__`` so future +Cython releases that change the attribute representation surface as a +test failure rather than a silent regression. + +If Cython / setuptools / a C compiler is unavailable in the test +environment, the whole module is skipped via pytest.importorskip. +""" + +from inspect import CO_ASYNC_GENERATOR, CO_COROUTINE + +import pytest + +cythonmodule = pytest.importorskip( + "samples.wiringcython.cythonmodule", + reason="Cython fixture not built (Cython/setuptools/C toolchain missing)", +) + +# Catch the silent-skip-on-empty-module case: ``pytest.importorskip`` falls +# through cleanly when the import succeeds even if the resulting module is +# missing the symbols this test suite depends on. Without this check, a +# regressed fixture (or a stray .so from a different revision) lets pytest +# report "0 items collected" as a successful empty pass — exactly the +# false-green CI mode that motivated the rest of this file. Fail loudly at +# collection time instead. +for _sym in ("sync_handler", "async_handler", "async_gen_handler", "HandlerClass"): + if not hasattr(cythonmodule, _sym): + raise AssertionError( + f"Cython fixture is missing required symbol: {_sym}. " + "The .so was importable but doesn't contain the expected handlers — " + "rebuild or clean stale artefacts under " + "tests/unit/samples/wiringcython/." + ) + +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, + _isasyncgenfunction_compat, + _iscoroutinefunction_compat, + _patched_registry, +) + + +@pytest.fixture +def container(): + c = Container() + c.wire(modules=[cythonmodule]) + yield c + c.unwire() + + +# -- discovery helpers -------------------------------------------------------- + + +def test_is_cyfunction_recognises_compiled_handlers(): + assert _is_cyfunction(cythonmodule.sync_handler) + assert _is_cyfunction(cythonmodule.async_handler) + assert _is_cyfunction(cythonmodule.async_gen_handler) + + +def test_is_cyfunction_rejects_pure_python(): + def py_fn(): + pass + + assert not _is_cyfunction(py_fn) + + +def test_is_function_like_accepts_both(): + def py_fn(): + pass + + assert _is_function_like(py_fn) + assert _is_function_like(cythonmodule.sync_handler) + + +def test_iscoroutinefunction_compat_on_cython_async_def(): + assert _iscoroutinefunction_compat(cythonmodule.async_handler) + assert not _iscoroutinefunction_compat(cythonmodule.sync_handler) + + +def test_isasyncgenfunction_compat_on_cython_async_gen(): + assert _isasyncgenfunction_compat(cythonmodule.async_gen_handler) + assert not _isasyncgenfunction_compat(cythonmodule.async_handler) + assert not _isasyncgenfunction_compat(cythonmodule.sync_handler) + + +# -- fallback-branch coverage (H4 — synthetic code objects) ------------------ +# +# Under Cython >= 3.0 the inspect.iscoroutinefunction / isasyncgenfunction +# checks already return True for compiled async / async-generator +# cyfunctions, so the ``__code__.co_flags`` fallback inside the compat +# helpers is unreachable from the fixture above. Exercise it directly +# with a callable carrying a forged ``__code__`` so the branch is covered. + + +class _FakeCode: + def __init__(self, co_flags: int) -> None: + self.co_flags = co_flags + + +class _FakeCallable: + """Inspection-only stand-in for an async cyfunction built on Cython < 3. + + ``inspect.iscoroutinefunction`` and ``isasyncgenfunction`` both return + False for instances of arbitrary classes — exactly the + pre-Cython-3.0 cyfunction shape the fallback was written for. + """ + + def __init__(self, co_flags: int) -> None: + self.__code__ = _FakeCode(co_flags) + + +def test_iscoroutinefunction_compat_falls_back_to_co_flags(): + fake_coro = _FakeCallable(CO_COROUTINE) + fake_plain = _FakeCallable(0) + assert _iscoroutinefunction_compat(fake_coro) + assert not _iscoroutinefunction_compat(fake_plain) + + +def test_isasyncgenfunction_compat_falls_back_to_co_flags(): + fake_asyncgen = _FakeCallable(CO_ASYNC_GENERATOR) + fake_plain = _FakeCallable(0) + assert _isasyncgenfunction_compat(fake_asyncgen) + assert not _isasyncgenfunction_compat(fake_plain) + + +def test_compat_helpers_handle_objects_without_code_attribute(): + """Lambdas / callable objects with no ``__code__`` must not crash.""" + + class _NoCode: + pass + + assert not _iscoroutinefunction_compat(_NoCode()) + assert not _isasyncgenfunction_compat(_NoCode()) + + +# -- descriptor-type pin (M7) ------------------------------------------------ + + +def test_handler_class_call_is_function_like(): + """Pin the descriptor representation of an ``async def __call__`` on a + Cython-compiled class so a future Cython release that swaps it for a + different descriptor surfaces as a failure here, not as silent + wiring drop-out. + """ + assert _is_function_like(cythonmodule.HandlerClass.__call__) + + +# -- runtime injection across all callable shapes ---------------------------- + + +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" + + +# -- override semantics survive compile boundary ----------------------------- + + +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" + + +# -- unwire empties injection bindings (C1 — positive assertion) ------------- +# +# ``_unpatch`` does not restore the original attribute; it calls +# ``_unbind_injections(fn)``, which invokes +# ``PatchedCallable.unwind_injections()`` and empties the ``injections`` +# dict in-place. The earlier version of this test caught any exception +# from a post-unwire call — false-pass shape, because the cyfunction +# raises ``AttributeError`` from a ``_Marker.get()`` lookup either way. +# Pin the actual contract instead: the registered ``PatchedCallable`` +# has its ``injections`` dict cleared while ``reference_injections`` is +# retained (so re-wiring can re-resolve without re-running discovery). + + +def test_unwire_clears_injection_bindings_on_compiled_module(): + c = Container() + c.wire(modules=[cythonmodule]) + + # After wire, the module attribute is the patched wrapper. The + # registry maps wrapper -> PatchedCallable. + wrapper = cythonmodule.sync_handler + patched = _patched_registry.get_callable(wrapper) + + assert patched is not None, ( + "wire() did not register the cyfunction in the patched registry" + ) + assert patched.reference_injections, ( + "wire() did not discover any reference injections on the cyfunction" + ) + assert patched.injections, ( + "wire() did not bind any runtime injections on the cyfunction" + ) + + c.unwire() + + assert patched.injections == {}, ( + "unwire() did not clear runtime injection bindings" + ) + # ``reference_injections`` is preserved across unwire so a subsequent + # wire() call can rebind without re-running discovery. + assert patched.reference_injections, ( + "unwire() unexpectedly discarded reference_injections" + ) diff --git a/tox.ini b/tox.ini index 2ad3ca2a9..2262efc64 100644 --- a/tox.ini +++ b/tox.ini @@ -18,6 +18,11 @@ deps= pydantic-settings werkzeug fast-depends + # Cython is required to build the .pyx fixture under + # tests/unit/samples/wiringcython/ that backs the wiring-vs-Cython + # regression coverage. Without it, tests/unit/wiring/test_cython.py + # skips silently. + cython>=3,<4 extras= yaml commands = pytest From ca3036c305f04348306775742fb9f51dfe4c13e2 Mon Sep 17 00:00:00 2001 From: Kieran David Evans Date: Tue, 19 May 2026 18:57:05 +0100 Subject: [PATCH 02/10] refactor: simplify Cython wiring per review Drop Cython <3 co_flags fallback helpers. inspect.iscoroutinefunction and inspect.isasyncgenfunction recognise Cython 3 cyfunctions natively via the CO_COROUTINE / CO_ASYNC_GENERATOR bits already on __code__. Replace manual cythonize + build_ext fixture harness with pyximport, which handles caching + cross-platform build. Parametrize the function-like predicate tests; drop the module-symbol-presence guard (build failure surfaces as ImportError). --- src/dependency_injector/wiring.py | 71 +--------- tests/unit/wiring/conftest.py | 112 +--------------- tests/unit/wiring/test_cython.py | 207 +++++------------------------- 3 files changed, 39 insertions(+), 351 deletions(-) diff --git a/src/dependency_injector/wiring.py b/src/dependency_injector/wiring.py index fb0adb1f7..62267c47a 100644 --- a/src/dependency_injector/wiring.py +++ b/src/dependency_injector/wiring.py @@ -6,8 +6,6 @@ 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, @@ -131,76 +129,15 @@ def is_werkzeug_local_proxy(obj: Any) -> bool: 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 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. - - 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 True for pure-Python functions and Cython-compiled functions.""" return isfunction(obj) or _is_cyfunction(obj) -def _iscoroutinefunction_compat(fn: Any) -> bool: - """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 - 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) - - from . import providers # noqa: E402 __all__ = ( @@ -895,9 +832,9 @@ def _get_patched( reference_closing=reference_closing, ) - if _iscoroutinefunction_compat(fn): + if iscoroutinefunction(fn): patched = _get_async_patched(fn, patched_object) - elif _isasyncgenfunction_compat(fn): + elif isasyncgenfunction(fn): patched = _get_async_gen_patched(fn, patched_object) else: patched = _get_sync_patched(fn, patched_object) diff --git a/tests/unit/wiring/conftest.py b/tests/unit/wiring/conftest.py index 4d0c8afcd..9c4aaf373 100644 --- a/tests/unit/wiring/conftest.py +++ b/tests/unit/wiring/conftest.py @@ -1,111 +1,5 @@ -"""Build Cython test fixtures at session start. +"""Enable on-import compilation of the .pyx wiring fixture via pyximport.""" -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. +import pyximport -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" - - -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: - _LOG.info("Cython fixture build skipped (missing dep): %s", exc) - return False - - # Bare module name (no dots) means setuptools writes the .so directly - # to ``/cythonmodule`` — 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() +pyximport.install(language_level=3) diff --git a/tests/unit/wiring/test_cython.py b/tests/unit/wiring/test_cython.py index 21afd49f2..f02129a7c 100644 --- a/tests/unit/wiring/test_cython.py +++ b/tests/unit/wiring/test_cython.py @@ -1,58 +1,18 @@ -"""Regression coverage: wiring discovery against Cython-compiled user modules. - -Builds the .pyx fixture in conftest.py::pytest_configure, then asserts every -callable shape the wire() dispatch handles is correctly recognised AND -runtime-injected when compiled to a .so: - - - module-level sync ``def`` - - module-level ``async def`` - - module-level async-generator ``async def`` with ``yield`` - - class with async ``def __call__`` - -Also asserts the cyfunction detection helpers behave correctly on both -compiled and pure-Python callables, exercises the ``CO_COROUTINE`` / -``CO_ASYNC_GENERATOR`` fallback branches via a synthetic code object, -and pins the descriptor type for class-attribute ``__call__`` so future -Cython releases that change the attribute representation surface as a -test failure rather than a silent regression. - -If Cython / setuptools / a C compiler is unavailable in the test -environment, the whole module is skipped via pytest.importorskip. -""" - -from inspect import CO_ASYNC_GENERATOR, CO_COROUTINE +"""Wiring discovery against Cython-compiled user modules.""" import pytest cythonmodule = pytest.importorskip( "samples.wiringcython.cythonmodule", - reason="Cython fixture not built (Cython/setuptools/C toolchain missing)", + reason="Cython fixture not built (Cython / C toolchain missing)", ) -# Catch the silent-skip-on-empty-module case: ``pytest.importorskip`` falls -# through cleanly when the import succeeds even if the resulting module is -# missing the symbols this test suite depends on. Without this check, a -# regressed fixture (or a stray .so from a different revision) lets pytest -# report "0 items collected" as a successful empty pass — exactly the -# false-green CI mode that motivated the rest of this file. Fail loudly at -# collection time instead. -for _sym in ("sync_handler", "async_handler", "async_gen_handler", "HandlerClass"): - if not hasattr(cythonmodule, _sym): - raise AssertionError( - f"Cython fixture is missing required symbol: {_sym}. " - "The .so was importable but doesn't contain the expected handlers — " - "rebuild or clean stale artefacts under " - "tests/unit/samples/wiringcython/." - ) - 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, - _isasyncgenfunction_compat, - _iscoroutinefunction_compat, _patched_registry, ) @@ -65,104 +25,31 @@ def container(): c.unwire() -# -- discovery helpers -------------------------------------------------------- - - -def test_is_cyfunction_recognises_compiled_handlers(): - assert _is_cyfunction(cythonmodule.sync_handler) - assert _is_cyfunction(cythonmodule.async_handler) - assert _is_cyfunction(cythonmodule.async_gen_handler) - - -def test_is_cyfunction_rejects_pure_python(): - def py_fn(): - pass - - assert not _is_cyfunction(py_fn) - - -def test_is_function_like_accepts_both(): - def py_fn(): - pass - - assert _is_function_like(py_fn) - assert _is_function_like(cythonmodule.sync_handler) - - -def test_iscoroutinefunction_compat_on_cython_async_def(): - assert _iscoroutinefunction_compat(cythonmodule.async_handler) - assert not _iscoroutinefunction_compat(cythonmodule.sync_handler) - - -def test_isasyncgenfunction_compat_on_cython_async_gen(): - assert _isasyncgenfunction_compat(cythonmodule.async_gen_handler) - assert not _isasyncgenfunction_compat(cythonmodule.async_handler) - assert not _isasyncgenfunction_compat(cythonmodule.sync_handler) - - -# -- fallback-branch coverage (H4 — synthetic code objects) ------------------ -# -# Under Cython >= 3.0 the inspect.iscoroutinefunction / isasyncgenfunction -# checks already return True for compiled async / async-generator -# cyfunctions, so the ``__code__.co_flags`` fallback inside the compat -# helpers is unreachable from the fixture above. Exercise it directly -# with a callable carrying a forged ``__code__`` so the branch is covered. - - -class _FakeCode: - def __init__(self, co_flags: int) -> None: - self.co_flags = co_flags - - -class _FakeCallable: - """Inspection-only stand-in for an async cyfunction built on Cython < 3. - - ``inspect.iscoroutinefunction`` and ``isasyncgenfunction`` both return - False for instances of arbitrary classes — exactly the - pre-Cython-3.0 cyfunction shape the fallback was written for. - """ - - def __init__(self, co_flags: int) -> None: - self.__code__ = _FakeCode(co_flags) - - -def test_iscoroutinefunction_compat_falls_back_to_co_flags(): - fake_coro = _FakeCallable(CO_COROUTINE) - fake_plain = _FakeCallable(0) - assert _iscoroutinefunction_compat(fake_coro) - assert not _iscoroutinefunction_compat(fake_plain) - - -def test_isasyncgenfunction_compat_falls_back_to_co_flags(): - fake_asyncgen = _FakeCallable(CO_ASYNC_GENERATOR) - fake_plain = _FakeCallable(0) - assert _isasyncgenfunction_compat(fake_asyncgen) - assert not _isasyncgenfunction_compat(fake_plain) - - -def test_compat_helpers_handle_objects_without_code_attribute(): - """Lambdas / callable objects with no ``__code__`` must not crash.""" - - class _NoCode: - pass - - assert not _iscoroutinefunction_compat(_NoCode()) - assert not _isasyncgenfunction_compat(_NoCode()) - - -# -- descriptor-type pin (M7) ------------------------------------------------ - - -def test_handler_class_call_is_function_like(): - """Pin the descriptor representation of an ``async def __call__`` on a - Cython-compiled class so a future Cython release that swaps it for a - different descriptor surfaces as a failure here, not as silent - wiring drop-out. - """ - assert _is_function_like(cythonmodule.HandlerClass.__call__) - - -# -- runtime injection across all callable shapes ---------------------------- +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): @@ -186,54 +73,24 @@ async def test_class_method_wired(container): assert await handler() == "injected" -# -- override semantics survive compile boundary ----------------------------- - - 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" -# -- unwire empties injection bindings (C1 — positive assertion) ------------- -# -# ``_unpatch`` does not restore the original attribute; it calls -# ``_unbind_injections(fn)``, which invokes -# ``PatchedCallable.unwind_injections()`` and empties the ``injections`` -# dict in-place. The earlier version of this test caught any exception -# from a post-unwire call — false-pass shape, because the cyfunction -# raises ``AttributeError`` from a ``_Marker.get()`` lookup either way. -# Pin the actual contract instead: the registered ``PatchedCallable`` -# has its ``injections`` dict cleared while ``reference_injections`` is -# retained (so re-wiring can re-resolve without re-running discovery). - - def test_unwire_clears_injection_bindings_on_compiled_module(): c = Container() c.wire(modules=[cythonmodule]) - # After wire, the module attribute is the patched wrapper. The - # registry maps wrapper -> PatchedCallable. wrapper = cythonmodule.sync_handler patched = _patched_registry.get_callable(wrapper) - assert patched is not None, ( - "wire() did not register the cyfunction in the patched registry" - ) - assert patched.reference_injections, ( - "wire() did not discover any reference injections on the cyfunction" - ) - assert patched.injections, ( - "wire() did not bind any runtime injections on the cyfunction" - ) + assert patched is not None + assert patched.reference_injections + assert patched.injections c.unwire() - assert patched.injections == {}, ( - "unwire() did not clear runtime injection bindings" - ) - # ``reference_injections`` is preserved across unwire so a subsequent - # wire() call can rebind without re-running discovery. - assert patched.reference_injections, ( - "unwire() unexpectedly discarded reference_injections" - ) + assert patched.injections == {} + assert patched.reference_injections From 6cde4f2eb72f96b1a750604eb78f42bd2f583462 Mon Sep 17 00:00:00 2001 From: Kieran David Evans Date: Tue, 19 May 2026 19:47:36 +0100 Subject: [PATCH 03/10] fix(test): guard pyximport import for non-Cython tox envs --- tests/unit/wiring/conftest.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unit/wiring/conftest.py b/tests/unit/wiring/conftest.py index 9c4aaf373..a2f21226d 100644 --- a/tests/unit/wiring/conftest.py +++ b/tests/unit/wiring/conftest.py @@ -1,5 +1,12 @@ -"""Enable on-import compilation of the .pyx wiring fixture via pyximport.""" +"""Enable on-import compilation of the .pyx wiring fixture via pyximport. -import pyximport +Cython is not installed in every tox env (e.g. pydantic-{v1,v2}), so the +import is guarded — test_cython.py skips at importorskip in that case. +""" -pyximport.install(language_level=3) +try: + import pyximport + + pyximport.install(language_level=3) +except ImportError: + pass From 74754f60292e88fe288e83563e9157b8c294e8b4 Mon Sep 17 00:00:00 2001 From: Kieran Evans Date: Tue, 19 May 2026 19:47:57 +0100 Subject: [PATCH 04/10] Update docs/wiring.rst Co-authored-by: ZipFile --- docs/wiring.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/wiring.rst b/docs/wiring.rst index e692a35f1..05884bf9f 100644 --- a/docs/wiring.rst +++ b/docs/wiring.rst @@ -695,11 +695,6 @@ extensions for a FastAPI / dependency-injector codebase: }, ) -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. - Few notes on performance ------------------------ From ce9a94e8990f58e38cbd99d1b2fa7ca34146ef9b Mon Sep 17 00:00:00 2001 From: Kieran Evans Date: Tue, 19 May 2026 19:48:05 +0100 Subject: [PATCH 05/10] Update docs/wiring.rst Co-authored-by: ZipFile --- docs/wiring.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/wiring.rst b/docs/wiring.rst index 05884bf9f..1a5eae594 100644 --- a/docs/wiring.rst +++ b/docs/wiring.rst @@ -685,13 +685,6 @@ extensions for a FastAPI / dependency-injector codebase: "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, }, ) From 47216213e823b001984a205fc443e2b3b99ca097 Mon Sep 17 00:00:00 2001 From: Kieran Evans Date: Tue, 19 May 2026 19:48:40 +0100 Subject: [PATCH 06/10] Update tests/unit/samples/wiringcython/cythonmodule.pyx Co-authored-by: ZipFile --- .../samples/wiringcython/cythonmodule.pyx | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/tests/unit/samples/wiringcython/cythonmodule.pyx b/tests/unit/samples/wiringcython/cythonmodule.pyx index 43f4558e2..4642dd49d 100644 --- a/tests/unit/samples/wiringcython/cythonmodule.pyx +++ b/tests/unit/samples/wiringcython/cythonmodule.pyx @@ -1,25 +1,4 @@ # 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 -""" from dependency_injector.wiring import Provide From 9715e4ecf18a4782d23bb866d00d8078acd31921 Mon Sep 17 00:00:00 2001 From: Kieran Evans Date: Tue, 19 May 2026 19:49:08 +0100 Subject: [PATCH 07/10] Update tox.ini Co-authored-by: ZipFile --- tox.ini | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 2262efc64..8ba6a6d50 100644 --- a/tox.ini +++ b/tox.ini @@ -18,10 +18,7 @@ deps= pydantic-settings werkzeug fast-depends - # Cython is required to build the .pyx fixture under - # tests/unit/samples/wiringcython/ that backs the wiring-vs-Cython - # regression coverage. Without it, tests/unit/wiring/test_cython.py - # skips silently. + # Cython is required to build tests/unit/samples/wiringcython/ cython>=3,<4 extras= yaml From 945ddbd583e6323db545dc7f5e762281d0690aa0 Mon Sep 17 00:00:00 2001 From: Kieran David Evans Date: Tue, 19 May 2026 19:49:07 +0100 Subject: [PATCH 08/10] Revert "fix(test): guard pyximport import for non-Cython tox envs" This reverts commit 6cde4f2eb72f96b1a750604eb78f42bd2f583462. --- tests/unit/wiring/conftest.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/unit/wiring/conftest.py b/tests/unit/wiring/conftest.py index a2f21226d..9c4aaf373 100644 --- a/tests/unit/wiring/conftest.py +++ b/tests/unit/wiring/conftest.py @@ -1,12 +1,5 @@ -"""Enable on-import compilation of the .pyx wiring fixture via pyximport. +"""Enable on-import compilation of the .pyx wiring fixture via pyximport.""" -Cython is not installed in every tox env (e.g. pydantic-{v1,v2}), so the -import is guarded — test_cython.py skips at importorskip in that case. -""" +import pyximport -try: - import pyximport - - pyximport.install(language_level=3) -except ImportError: - pass +pyximport.install(language_level=3) From 6eeb37faa3347ca7fd9df43284e7b90b842a1055 Mon Sep 17 00:00:00 2001 From: Kieran David Evans Date: Tue, 19 May 2026 20:09:27 +0100 Subject: [PATCH 09/10] docs: note @cython.annotation_typing(False) for FastAPI views/deps --- docs/wiring.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/wiring.rst b/docs/wiring.rst index 1a5eae594..e0cbed5ff 100644 --- a/docs/wiring.rst +++ b/docs/wiring.rst @@ -688,6 +688,27 @@ extensions for a FastAPI / dependency-injector codebase: }, ) +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 ------------------------ From 11fa4d260276dd586d8bc53dc89d62be72cf85a9 Mon Sep 17 00:00:00 2001 From: Kieran David Evans Date: Tue, 19 May 2026 20:18:23 +0100 Subject: [PATCH 10/10] fix(test): move pyximport into test_cython, drop wiring conftest pytest.importorskip("Cython") gates the pyximport.install call so non-Cython tox envs (pydantic-v1, pydantic-v2) skip the module at collection instead of crashing on missing pyximport. --- tests/unit/wiring/conftest.py | 5 ----- tests/unit/wiring/test_cython.py | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 tests/unit/wiring/conftest.py diff --git a/tests/unit/wiring/conftest.py b/tests/unit/wiring/conftest.py deleted file mode 100644 index 9c4aaf373..000000000 --- a/tests/unit/wiring/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Enable on-import compilation of the .pyx wiring fixture via pyximport.""" - -import pyximport - -pyximport.install(language_level=3) diff --git a/tests/unit/wiring/test_cython.py b/tests/unit/wiring/test_cython.py index f02129a7c..80a9a42a8 100644 --- a/tests/unit/wiring/test_cython.py +++ b/tests/unit/wiring/test_cython.py @@ -2,6 +2,12 @@ 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)",