diff --git a/Makefile b/Makefile index 29e4086f..a796f484 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,10 @@ VERSION := $(shell python setup.py --version) export COVERAGE_RCFILE := pyproject.toml +export CIBW_ENVIRONMENT_PASS_LINUX := CFLAGS PIP_CONFIG_SETTINGS DEPENDENCY_INJECTOR_LIMITED_API +export PIP_CONFIG_SETTINGS ?= build_ext=-j4 +export DEPENDENCY_INJECTOR_LIMITED_API ?= 1 +export CFLAGS ?= -g0 clean: # Clean sources @@ -63,3 +67,6 @@ publish: # Create and upload tag git tag -a $(VERSION) -m 'version $(VERSION)' git push --tags + +wheels: + cibuildwheel --output-dir wheelhouse diff --git a/docs/containers/declarative.rst b/docs/containers/declarative.rst index febc0bf9..e464f49e 100644 --- a/docs/containers/declarative.rst +++ b/docs/containers/declarative.rst @@ -16,7 +16,7 @@ The declarative container providers should only be used when you have the contai Working with the providers of the container on the class level will influence all further instances. -The declarative container can not have any methods or any other attributes then providers. +A declarative container cannot have any methods or attributes other than providers. The container class provides next attributes: diff --git a/docs/main/changelog.rst b/docs/main/changelog.rst index 4ebbcbc3..a9ec5880 100644 --- a/docs/main/changelog.rst +++ b/docs/main/changelog.rst @@ -7,6 +7,13 @@ that were made in every particular version. From version 0.7.6 *Dependency Injector* framework strictly follows `Semantic versioning`_ +4.48.2 +------ + +- Add ``warn_unresolved=True`` to ``WiringConfiguration`` and ``container.wire()`` + to produce warnings on unresolved string identifiers. +- ABI3 wheels are now built only for CPython version >=3.10 (see issue `#919 `_). + 4.48.1 ------ diff --git a/docs/providers/singleton.rst b/docs/providers/singleton.rst index 5c2d517f..eeb8ca2e 100644 --- a/docs/providers/singleton.rst +++ b/docs/providers/singleton.rst @@ -33,7 +33,7 @@ factories: - :ref:`factory-specialize-provided-type` - :ref:`abstract-factory` -``Singleton`` provider scope is tied to the container. Two different containers will provider +``Singleton`` provider scope is tied to the container. Two different containers will provide two different singleton objects: .. literalinclude:: ../../examples/providers/singleton_multiple_containers.py diff --git a/docs/wiring.rst b/docs/wiring.rst index bb6ba156..3d5778c4 100644 --- a/docs/wiring.rst +++ b/docs/wiring.rst @@ -251,6 +251,32 @@ To inject a container use special identifier ````: def foo(container: Container = Provide[""]) -> None: ... +Caveats +~~~~~~~ + +While using string identifiers you may not notice a typo in the identifier until the code is executed. +In order to aid with catching such errors early, you may pass `warn_unresolved=True` to the ``wire`` method and/or :class:`WiringConfiguration`: + +.. code-block:: python + :emphasize-lines: 4 + + class Container(containers.DeclarativeContainer): + wiring_config = containers.WiringConfiguration( + modules=["yourapp.module"], + warn_unresolved=True, + ) + +Or: + +.. code-block:: python + :emphasize-lines: 4 + + container = Container() + container.wire( + modules=["yourapp.module"], + warn_unresolved=True, + ) + Making injections into modules and class attributes --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index ef0b946d..fe89efaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools", "Cython>=3.1.1"] +requires = ["setuptools", "Cython>=3.1.4"] build-backend = "setuptools.build_meta" [project] @@ -54,7 +54,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ # typing.Annotated since v3.9 - # typing.Self since v3.11 + # typing.Self and typing.assert_never since v3.11 "typing-extensions; python_version<'3.11'", ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 408b9bb6..9c33d385 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,4 @@ -cython==3.1.1 +cython==3.1.4 setuptools pytest pytest-asyncio diff --git a/setup.py b/setup.py index 5f4669e4..ba9d3068 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ import os import sys +import sysconfig from Cython.Build import cythonize from Cython.Compiler import Options @@ -11,6 +12,8 @@ limited_api = ( os.environ.get("DEPENDENCY_INJECTOR_LIMITED_API") == "1" and sys.implementation.name == "cpython" + and sys.version_info >= (3, 10) + and not sysconfig.get_config_var("Py_GIL_DISABLED") ) defined_macros = [] options = {} @@ -34,8 +37,8 @@ if limited_api: options.setdefault("bdist_wheel", {}) - options["bdist_wheel"]["py_limited_api"] = "cp38" - defined_macros.append(("Py_LIMITED_API", "0x03080000")) + options["bdist_wheel"]["py_limited_api"] = "cp310" + defined_macros.append(("Py_LIMITED_API", "0x030A0000")) setup( options=options, diff --git a/src/dependency_injector/__init__.py b/src/dependency_injector/__init__.py index af252a31..404510f4 100644 --- a/src/dependency_injector/__init__.py +++ b/src/dependency_injector/__init__.py @@ -1,6 +1,6 @@ """Top-level package.""" -__version__ = "4.48.1" +__version__ = "4.48.2" """Version number. :type: str diff --git a/src/dependency_injector/containers.pyi b/src/dependency_injector/containers.pyi index f21a8791..95eef00a 100644 --- a/src/dependency_injector/containers.pyi +++ b/src/dependency_injector/containers.pyi @@ -72,6 +72,7 @@ class Container: modules: Optional[Iterable[Any]] = None, packages: Optional[Iterable[Any]] = None, from_package: Optional[str] = None, + warn_unresolved: bool = False, ) -> None: ... def unwire(self) -> None: ... def init_resources(self, resource_type: Type[Resource[Any]] = Resource) -> Optional[Awaitable[None]]: ... diff --git a/src/dependency_injector/containers.pyx b/src/dependency_injector/containers.pyx index 99762da2..f9e8ea91 100644 --- a/src/dependency_injector/containers.pyx +++ b/src/dependency_injector/containers.pyx @@ -20,15 +20,31 @@ from .wiring import wire, unwire class WiringConfiguration: """Container wiring configuration.""" - def __init__(self, modules=None, packages=None, from_package=None, auto_wire=True, keep_cache=False): + def __init__( + self, + modules=None, + packages=None, + from_package=None, + auto_wire=True, + keep_cache=False, + warn_unresolved=False, + ): self.modules = [*modules] if modules else [] self.packages = [*packages] if packages else [] self.from_package = from_package self.auto_wire = auto_wire self.keep_cache = keep_cache + self.warn_unresolved = warn_unresolved def __deepcopy__(self, memo=None): - return self.__class__(self.modules, self.packages, self.from_package, self.auto_wire, self.keep_cache) + return self.__class__( + self.modules, + self.packages, + self.from_package, + self.auto_wire, + self.keep_cache, + self.warn_unresolved, + ) class Container: @@ -259,7 +275,14 @@ class DynamicContainer(Container): """Check if auto wiring is needed.""" return self.wiring_config.auto_wire is True - def wire(self, modules=None, packages=None, from_package=None, keep_cache=None): + def wire( + self, + modules=None, + packages=None, + from_package=None, + keep_cache=None, + warn_unresolved=False, + ): """Wire container providers with provided packages and modules. :rtype: None @@ -298,6 +321,7 @@ class DynamicContainer(Container): modules=modules, packages=packages, keep_cache=keep_cache, + warn_unresolved=warn_unresolved, ) if modules: diff --git a/src/dependency_injector/providers.pyx b/src/dependency_injector/providers.pyx index 43e49d7e..d8a8ab35 100644 --- a/src/dependency_injector/providers.pyx +++ b/src/dependency_injector/providers.pyx @@ -1599,7 +1599,7 @@ cdef class ConfigurationOption(Provider): return self._root def get_name(self): - return ".".join((self._root.get_name(), self._get_self_name())) + return f"{self._root.get_name()}.{self._get_self_name()}" def get_name_segments(self): return self._name diff --git a/src/dependency_injector/wiring.py b/src/dependency_injector/wiring.py index 6d5d1510..211fdcde 100644 --- a/src/dependency_injector/wiring.py +++ b/src/dependency_injector/wiring.py @@ -30,9 +30,9 @@ from warnings import warn try: - from typing import Self + from typing import Self, assert_never except ImportError: - from typing_extensions import Self + from typing_extensions import Self, assert_never try: from functools import cache @@ -139,6 +139,10 @@ class DIWiringWarning(RuntimeWarning): """Base class for all warnings raised by the wiring module.""" +class UnresolvedMarkerWarning(DIWiringWarning): + """Warning raised when a marker with string identifier cannot be resolved against container.""" + + class PatchedRegistry: def __init__(self) -> None: @@ -433,6 +437,7 @@ def wire( # noqa: C901 modules: Optional[Iterable[ModuleType]] = None, packages: Optional[Iterable[ModuleType]] = None, keep_cache: bool = False, + warn_unresolved: bool = False, ) -> None: """Wire container providers with provided packages and modules.""" modules = [*modules] if modules else [] @@ -449,9 +454,23 @@ def wire( # noqa: C901 continue if _is_marker(member): - _patch_attribute(module, member_name, member, providers_map) + _patch_attribute( + module, + member_name, + member, + providers_map, + warn_unresolved=warn_unresolved, + warn_unresolved_stacklevel=1, + ) elif inspect.isfunction(member): - _patch_fn(module, member_name, member, providers_map) + _patch_fn( + module, + member_name, + member, + providers_map, + warn_unresolved=warn_unresolved, + warn_unresolved_stacklevel=1, + ) elif inspect.isclass(member): cls = member try: @@ -463,15 +482,30 @@ def wire( # noqa: C901 for cls_member_name, cls_member in cls_members: if _is_marker(cls_member): _patch_attribute( - cls, cls_member_name, cls_member, providers_map + cls, + cls_member_name, + cls_member, + providers_map, + warn_unresolved=warn_unresolved, + warn_unresolved_stacklevel=1, ) elif _is_method(cls_member): _patch_method( - cls, cls_member_name, cls_member, providers_map + cls, + cls_member_name, + cls_member, + providers_map, + warn_unresolved=warn_unresolved, + warn_unresolved_stacklevel=1, ) for patched in _patched_registry.get_callables_from_module(module): - _bind_injections(patched, providers_map) + _bind_injections( + patched, + providers_map, + warn_unresolved=warn_unresolved, + warn_unresolved_stacklevel=1, + ) if not keep_cache: clear_cache() @@ -524,6 +558,8 @@ def _patch_fn( name: str, fn: Callable[..., Any], providers_map: ProvidersMap, + warn_unresolved: bool = False, + warn_unresolved_stacklevel: int = 0, ) -> None: if not _is_patched(fn): reference_injections, reference_closing = _fetch_reference_injections(fn) @@ -531,7 +567,12 @@ def _patch_fn( return fn = _get_patched(fn, reference_injections, reference_closing) - _bind_injections(fn, providers_map) + _bind_injections( + fn, + providers_map, + warn_unresolved=warn_unresolved, + warn_unresolved_stacklevel=warn_unresolved_stacklevel + 1, + ) setattr(module, name, fn) @@ -541,6 +582,8 @@ def _patch_method( name: str, method: Callable[..., Any], providers_map: ProvidersMap, + warn_unresolved: bool = False, + warn_unresolved_stacklevel: int = 0, ) -> None: if ( hasattr(cls, "__dict__") @@ -558,7 +601,12 @@ def _patch_method( return fn = _get_patched(fn, reference_injections, reference_closing) - _bind_injections(fn, providers_map) + _bind_injections( + fn, + providers_map, + warn_unresolved=warn_unresolved, + warn_unresolved_stacklevel=warn_unresolved_stacklevel + 1, + ) if fn is method: # Hotfix, see: https://github.com/ets-labs/python-dependency-injector/issues/884 @@ -594,9 +642,17 @@ def _patch_attribute( name: str, marker: "_Marker", providers_map: ProvidersMap, + warn_unresolved: bool = False, + warn_unresolved_stacklevel: int = 0, ) -> None: provider = providers_map.resolve_provider(marker.provider, marker.modifier) if provider is None: + if warn_unresolved: + warn( + f"Unresolved marker {name} in {member!r}", + UnresolvedMarkerWarning, + stacklevel=warn_unresolved_stacklevel + 2, + ) return _patched_registry.register_attribute(PatchedAttribute(member, name, marker)) @@ -673,7 +729,12 @@ def _fetch_reference_injections( # noqa: C901 return injections, closing -def _bind_injections(fn: Callable[..., Any], providers_map: ProvidersMap) -> None: +def _bind_injections( + fn: Callable[..., Any], + providers_map: ProvidersMap, + warn_unresolved: bool = False, + warn_unresolved_stacklevel: int = 0, +) -> None: patched_callable = _patched_registry.get_callable(fn) if patched_callable is None: return @@ -682,6 +743,12 @@ def _bind_injections(fn: Callable[..., Any], providers_map: ProvidersMap) -> Non provider = providers_map.resolve_provider(marker.provider, marker.modifier) if provider is None: + if warn_unresolved: + warn( + f"Unresolved marker {injection} in {fn.__qualname__}", + UnresolvedMarkerWarning, + stacklevel=warn_unresolved_stacklevel + 2, + ) continue if isinstance(marker, Provide): @@ -791,6 +858,9 @@ def modify( ) -> providers.Provider: return provider.as_(self.type_) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.type_!r})" + def as_int() -> TypeModifier: """Return int type modifier.""" @@ -809,8 +879,8 @@ def as_(type_: Type) -> TypeModifier: class RequiredModifier(Modifier): - def __init__(self) -> None: - self.type_modifier = None + def __init__(self, type_modifier: Optional[TypeModifier] = None) -> None: + self.type_modifier = type_modifier def as_int(self) -> Self: self.type_modifier = TypeModifier(int) @@ -834,6 +904,11 @@ def modify( provider = provider.as_(self.type_modifier.type_) return provider + def __repr__(self) -> str: + if self.type_modifier: + return f"{self.__class__.__name__}({self.type_modifier!r})" + return f"{self.__class__.__name__}()" + def required() -> RequiredModifier: """Return required modifier.""" @@ -853,6 +928,9 @@ def modify( invariant_segment = providers_map.resolve_provider(self.id) return provider[invariant_segment] + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.id!r})" + def invariant(id: str) -> InvariantModifier: """Return invariant modifier.""" @@ -893,8 +971,28 @@ def modify( provider = provider[value] elif type_ == ProvidedInstance.TYPE_CALL: provider = provider.call() + else: + assert_never(type_) return provider + def _format_segments(self) -> str: + segments = [] + for type_, value in self.segments: + if type_ == ProvidedInstance.TYPE_ATTRIBUTE: + segments.append(f".{value}") + elif type_ == ProvidedInstance.TYPE_ITEM: + segments.append(f"[{value!r}]") + elif type_ == ProvidedInstance.TYPE_CALL: + segments.append(".call()") + else: + assert_never(type_) + return "".join(segments) + + __str__ = _format_segments + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(){self._format_segments()}" + def provided() -> ProvidedInstance: """Return provided instance modifier.""" @@ -910,7 +1008,7 @@ def provided() -> ProvidedInstance: ] -if TYPE_CHECKING: +if TYPE_CHECKING: # noqa class _Marker(Protocol): __IS_MARKER__: bool @@ -918,6 +1016,7 @@ class _Marker(Protocol): def __call__(self) -> Self: ... def __getattr__(self, item: str) -> Self: ... def __getitem__(self, item: Any) -> Any: ... + def __repr__(self) -> str: ... Provide: _Marker Provider: _Marker @@ -946,6 +1045,12 @@ def __class_getitem__(cls, item: MarkerItem) -> Self: def __call__(self) -> Self: return self + def __repr__(self) -> str: + cls_name = self.__class__.__name__ + if self.modifier: + return f"{cls_name}[{self.provider!r}, {self.modifier!r}]" + return f"{cls_name}[{self.provider!r}]" + class Provide(_Marker): ... class Provider(_Marker): ... diff --git a/tests/unit/samples/wiringstringids/missing.py b/tests/unit/samples/wiringstringids/missing.py new file mode 100644 index 00000000..b8bafae5 --- /dev/null +++ b/tests/unit/samples/wiringstringids/missing.py @@ -0,0 +1,15 @@ +from dependency_injector.wiring import Provide, inject + +missing_obj: object = Provide["missing"] + + +class TestMissingClass: + obj: object = Provide["missing"] + + def method(self, obj: object = Provide["missing"]) -> object: + return obj + + +@inject +def test_missing_function(obj: object = Provide["missing"]): + return obj diff --git a/tests/unit/wiring/provider_ids/test_main_py36.py b/tests/unit/wiring/provider_ids/test_main_py36.py index 15ac31c0..a36e50e8 100644 --- a/tests/unit/wiring/provider_ids/test_main_py36.py +++ b/tests/unit/wiring/provider_ids/test_main_py36.py @@ -1,5 +1,6 @@ """Main wiring tests.""" +import re from decimal import Decimal from dependency_injector import errors @@ -67,7 +68,7 @@ def test_module_attributes_wiring(): def test_module_attribute_wiring_with_invalid_marker(container: Container): from samples.wiring import module_invalid_attr_injection - with raises(Exception, match="Unknown type of marker {0}".format(module_invalid_attr_injection.service)): + with raises(Exception, match=re.escape("Unknown type of marker {0}".format(module_invalid_attr_injection.service))): container.wire(modules=[module_invalid_attr_injection]) diff --git a/tests/unit/wiring/string_ids/test_main_py36.py b/tests/unit/wiring/string_ids/test_main_py36.py index 8125481a..3a4e344e 100644 --- a/tests/unit/wiring/string_ids/test_main_py36.py +++ b/tests/unit/wiring/string_ids/test_main_py36.py @@ -1,14 +1,21 @@ """Main wiring tests.""" +import re from decimal import Decimal -from pytest import fixture, mark, raises +from pytest import fixture, mark, raises, warns from samples.wiringstringids import module, package, resourceclosing from samples.wiringstringids.container import Container, SubContainer from samples.wiringstringids.service import Service from dependency_injector import errors -from dependency_injector.wiring import Closing, Provide, Provider, wire +from dependency_injector.wiring import ( + Closing, + Provide, + Provider, + UnresolvedMarkerWarning, + wire, +) @fixture(autouse=True) @@ -68,10 +75,20 @@ def test_module_attributes_wiring(): def test_module_attribute_wiring_with_invalid_marker(container: Container): from samples.wiringstringids import module_invalid_attr_injection - with raises(Exception, match="Unknown type of marker {0}".format(module_invalid_attr_injection.service)): + with raises(Exception, match=re.escape("Unknown type of marker {0}".format(module_invalid_attr_injection.service))): container.wire(modules=[module_invalid_attr_injection]) +def test_warn_unresolved_marker(container: Container): + from samples.wiringstringids import missing + + with warns( + UnresolvedMarkerWarning, + match=r"^Unresolved marker .+ in .+$", + ): + container.wire(modules=[missing], warn_unresolved=True) + + def test_class_wiring(): test_class_object = module.TestClass() assert isinstance(test_class_object.service, Service) diff --git a/tests/unit/wiring/test_reprs.py b/tests/unit/wiring/test_reprs.py new file mode 100644 index 00000000..24da7424 --- /dev/null +++ b/tests/unit/wiring/test_reprs.py @@ -0,0 +1,42 @@ +from dependency_injector.wiring import ( + Closing, + InvariantModifier, + Provide, + ProvidedInstance, + RequiredModifier, + TypeModifier, +) + + +def test_type_modifier_repr() -> None: + assert repr(TypeModifier(int)) == f"TypeModifier({int!r})" + + +def test_required_modifier_repr() -> None: + assert repr(RequiredModifier()) == "RequiredModifier()" + + +def test_required_modifier_with_type_repr() -> None: + type_modifier = TypeModifier(int) + required_modifier = RequiredModifier(type_modifier) + assert repr(required_modifier) == f"RequiredModifier({type_modifier!r})" + + +def test_invariant_modifier_repr() -> None: + assert repr(InvariantModifier("test")) == "InvariantModifier('test')" + + +def test_provided_instance_repr() -> None: + provided_instance = ProvidedInstance().test["attr"].call() + + assert repr(provided_instance) == "ProvidedInstance().test['attr'].call()" + + +def test_marker_repr() -> None: + assert repr(Closing[Provide["test"]]) == "Closing[Provide['test']]" + + +def test_marker_with_modifier_repr() -> None: + marker = Provide["test", RequiredModifier()] + + assert repr(marker) == "Provide['test', RequiredModifier()]"