Repository: reagento/dishka
Pull request #155 (2024-05-16 22:46:48)

Pass kwargs to factories


Patch:
diff --git a/.github/workflows/setup.yml b/.github/workflows/setup.yml
index 16e517f..25312d0 100644
--- a/.github/workflows/setup.yml
+++ b/.github/workflows/setup.yml
@@ -1,4 +1,4 @@
-# This workflow will install Python dependencies, run tests and lint
+# This workflow will install Python dependencies, run tests and lint with a single version of Python
 # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
 
 name: CI
diff --git a/src/dishka/dependency_source/alias.py b/src/dishka/dependency_source/alias.py
index 7660a5d..1b74bc1 100644
--- a/src/dishka/dependency_source/alias.py
+++ b/src/dishka/dependency_source/alias.py
@@ -32,6 +32,7 @@ class Alias:
             provides=self.provides.with_component(component),
             is_to_bind=False,
             dependencies=[self.source.with_component(component)],
+            kw_dependencies={},
             type_=FactoryType.ALIAS,
             cache=self.cache,
         )
diff --git a/src/dishka/dependency_source/context_var.py b/src/dishka/dependency_source/context_var.py
index 401b1a1..98b2d32 100644
--- a/src/dishka/dependency_source/context_var.py
+++ b/src/dishka/dependency_source/context_var.py
@@ -32,6 +32,7 @@ class ContextVariable:
                 provides=self.provides,
                 is_to_bind=False,
                 dependencies=[],
+                kw_dependencies={},
                 type_=FactoryType.CONTEXT,
                 cache=False,
             )
diff --git a/src/dishka/dependency_source/decorator.py b/src/dishka/dependency_source/decorator.py
index 5fd9a9c..7c01300 100644
--- a/src/dishka/dependency_source/decorator.py
+++ b/src/dishka/dependency_source/decorator.py
@@ -36,6 +36,14 @@ class Decorator:
                 ).with_component(component)
                 for dep in self.factory.dependencies
             ],
+            kw_dependencies={
+                name: (
+                    new_dependency
+                    if dep.type_hint == self.provides.type_hint
+                    else dep
+                ).with_component(component)
+                for name, dep in self.factory.kw_dependencies.items()
+            },
             type_=self.factory.type,
             cache=cache,
         )
diff --git a/src/dishka/dependency_source/factory.py b/src/dishka/dependency_source/factory.py
index 004455a..510f6df 100644
--- a/src/dishka/dependency_source/factory.py
+++ b/src/dishka/dependency_source/factory.py
@@ -1,10 +1,9 @@
 from collections.abc import (
+    Mapping,
     Sequence,
 )
 from enum import Enum
-from typing import (
-    Any,
-)
+from typing import Any
 
 from dishka.entities.component import Component
 from dishka.entities.key import DependencyKey
@@ -23,7 +22,8 @@ class FactoryType(Enum):
 
 class Factory:
     __slots__ = (
-        "dependencies", "source", "provides", "scope", "type",
+        "dependencies", "kw_dependencies",
+        "source", "provides", "scope", "type",
         "is_to_bind", "cache",
     )
 
@@ -31,6 +31,7 @@ class Factory:
             self,
             *,
             dependencies: Sequence[DependencyKey],
+            kw_dependencies: Mapping[str, DependencyKey],
             source: Any,
             provides: DependencyKey,
             scope: BaseScope | None,
@@ -39,6 +40,7 @@ class Factory:
             cache: bool,
     ):
         self.dependencies = dependencies
+        self.kw_dependencies = kw_dependencies
         self.source = source
         self.provides = provides
         self.scope = scope
@@ -58,6 +60,7 @@ class Factory:
             dependencies = self.dependencies[:]
         return Factory(
             dependencies=dependencies,
+            kw_dependencies=self.kw_dependencies,
             source=source,
             provides=self.provides,
             scope=scope,
@@ -71,6 +74,10 @@ class Factory:
             dependencies=[
                 d.with_component(component) for d in self.dependencies
             ],
+            kw_dependencies={
+                name: d.with_component(component)
+                for name, d in self.kw_dependencies.items()
+            },
             source=self.source,
             provides=self.provides.with_component(component),
             scope=self.scope,
diff --git a/src/dishka/dependency_source/make_factory.py b/src/dishka/dependency_source/make_factory.py
index f9db170..7b4e766 100644
--- a/src/dishka/dependency_source/make_factory.py
+++ b/src/dishka/dependency_source/make_factory.py
@@ -9,6 +9,7 @@ from collections.abc import (
     Sequence,
 )
 from inspect import (
+    Parameter,
     isasyncgenfunction,
     isbuiltin,
     isclass,
@@ -214,10 +215,17 @@ def _make_factory_by_class(
         ) from e
 
     hints.pop("return", _empty)
+    params = signature(init).parameters
+    kw_dependency_keys = {
+        name: hint_to_dependency_key(hints.pop(name))
+        for name, param in params.items()
+        if param.kind is Parameter.KEYWORD_ONLY
+    }
     dependencies = list(hints.values())
 
     return Factory(
         dependencies=hints_to_dependency_keys(dependencies),
+        kw_dependencies=kw_dependency_keys,
         type_=FactoryType.FACTORY,
         source=source,
         scope=scope,
@@ -267,7 +275,14 @@ def _make_factory_by_function(
             # if we will bind factory to provider instance
             hints = {self.name: Any, **hints}
     possible_dependency = hints.pop("return", _empty)
+
+    kw_dependency_keys = {
+        name: hint_to_dependency_key(hints.pop(name))
+        for name, param in params.items()
+        if param.kind is Parameter.KEYWORD_ONLY
+    }
     dependencies = list(hints.values())
+
     if not provides:
         if possible_dependency is _empty:
             name = getattr(source, "__qualname__", "") or str(source)
@@ -280,6 +295,7 @@ def _make_factory_by_function(
             raise TypeError(f"Failed to analyze `{name}`. \n" + str(e)) from e
     return Factory(
         dependencies=hints_to_dependency_keys(dependencies),
+        kw_dependencies=kw_dependency_keys,
         type_=factory_type,
         source=source,
         scope=scope,
@@ -316,8 +332,17 @@ def _make_factory_by_static_method(
             f"Or, create a separate factory with all types imported.",
             name=e.name,
         ) from e
+
     possible_dependency = hints.pop("return", _empty)
+
+    params = signature(source).parameters
+    kw_dependency_keys = {
+        name: hint_to_dependency_key(hints.pop(name))
+        for name, param in params.items()
+        if param.kind is Parameter.KEYWORD_ONLY
+    }
     dependencies = list(hints.values())
+
     if not provides:
         if possible_dependency is _empty:
             name = getattr(source, "__qualname__", "") or str(source)
@@ -330,6 +355,7 @@ def _make_factory_by_static_method(
             raise TypeError(f"Failed to analyze `{name}`. \n" + str(e)) from e
     return Factory(
         dependencies=hints_to_dependency_keys(dependencies),
+        kw_dependencies=kw_dependency_keys,
         type_=factory_type,
         source=source,
         scope=scope,
@@ -363,6 +389,7 @@ def _make_factory_by_other_callable(
         dependencies = factory.dependencies
     return Factory(
         dependencies=dependencies,
+        kw_dependencies=factory.kw_dependencies,
         type_=factory.type,
         source=source,
         scope=scope,
diff --git a/src/dishka/dependency_source/unpack_provides.py b/src/dishka/dependency_source/unpack_provides.py
index de27e40..e1a847e 100644
--- a/src/dishka/dependency_source/unpack_provides.py
+++ b/src/dishka/dependency_source/unpack_provides.py
@@ -24,6 +24,7 @@ def unpack_factory(factory: Factory) -> Sequence[DependencySource]:
     ]
     res.append(Factory(
         dependencies=factory.dependencies,
+        kw_dependencies=factory.kw_dependencies,
         type_=factory.type,
         source=factory.source,
         scope=factory.scope,
diff --git a/src/dishka/factory_compiler.py b/src/dishka/factory_compiler.py
index 710d125..63b7f13 100644
--- a/src/dishka/factory_compiler.py
+++ b/src/dishka/factory_compiler.py
@@ -10,9 +10,10 @@ For each template we expect global variables:
 * UnsupportedFactoryError
 
 When formatting substituted:
-* await - "async " for async container or empty string
+* await - "await " for async container or empty string
 * async - "async " for async container or empty string
 * args - "getter(arg1), getter(arg2)..." or async version
+* kwargs - "arg1=getter(arg1), arg2=getter(arg2)..." or async version
 * cache - expression to save cache
 """
 
@@ -21,11 +22,20 @@ from .dependency_source import Factory, FactoryType
 from .exceptions import NoContextValueError, UnsupportedFactoryError
 
 
-def make_args(names: list[str]):
-    return ", ".join(
-        "{await} getter(%s)" % arg  # noqa: UP031
-        for arg in names
+def make_args(args: list[str], kwargs: list[str]) -> str:
+    res = ", ".join(
+        f"{{await}}getter({arg})"
+        for arg in args
     )
+    if not kwargs:
+        return res
+    if res:
+        res += ", "
+    res += ", ".join(
+        f"{arg}={{await}}getter({arg})"
+        for arg in kwargs
+    )
+    return res
 
 
 GENERATOR = """
@@ -98,7 +108,12 @@ CACHE = "context[provides] = solved"
 
 
 def compile_factory(*, factory: Factory, is_async: bool) -> CompiledFactory:
-    names = {f"arg{i}": dep for i, dep in enumerate(factory.dependencies)}
+    args = {
+        f"_dishka_arg{i}": dep
+        for i, dep in enumerate(factory.dependencies)
+    }
+    kwargs = factory.kw_dependencies
+
     if is_async:
         async_ = "async "
         await_ = "await "
@@ -112,11 +127,13 @@ def compile_factory(*, factory: Factory, is_async: bool) -> CompiledFactory:
     else:
         cache = ""
 
-    args = make_args(list(names)).format_map({"await": await_})
+    args_str = make_args(list(args), list(kwargs)).format_map({
+        "await": await_,
+    })
     body = body_template.format_map({
         "async": async_,
         "await": await_,
-        "args": args,
+        "args": args_str,
         "cache": cache,
     })
     func_globals = {
@@ -126,7 +143,8 @@ def compile_factory(*, factory: Factory, is_async: bool) -> CompiledFactory:
         "Exit": Exit,
         "NoContextValueError": NoContextValueError,
         "UnsupportedFactoryError": UnsupportedFactoryError,
-        **names,
+        **args,
+        **kwargs,
     }
     exec(body, func_globals)  # noqa: S102
     return func_globals["get"]
diff --git a/src/dishka/registry.py b/src/dishka/registry.py
index f273943..4e3b09b 100644
--- a/src/dishka/registry.py
+++ b/src/dishka/registry.py
@@ -111,10 +111,24 @@ class Registry:
             new_dependencies.append(DependencyKey(
                 hint, source_dependency.component,
             ))
+        new_kw_dependencies: dict[str, DependencyKey] = {}
+        for name, source_dependency in factory.kw_dependencies.items():
+            hint = source_dependency.type_hint
+            if isinstance(hint, TypeVar):
+                hint = params_replacement[hint]
+            elif get_origin(hint):
+                hint = hint[tuple(
+                    params_replacement[param]
+                    for param in get_type_vars(hint)
+                )]
+            new_kw_dependencies[name] = DependencyKey(
+                hint, source_dependency.component,
+            )
         return Factory(
             source=factory.source,
             provides=dependency_key,
             dependencies=new_dependencies,
+            kw_dependencies=new_kw_dependencies,
             is_to_bind=factory.is_to_bind,
             type_=factory.type,
             scope=factory.scope,
@@ -154,6 +168,11 @@ class GraphValidator:
                 # ignore TypeVar parameters
                 if not isinstance(dep.type_hint, TypeVar):
                     self._validate_key(dep, registry_index)
+            for dep in factory.kw_dependencies.values():
+                # ignore TypeVar parameters
+                if not isinstance(dep.type_hint, TypeVar):
+                    self._validate_key(dep, registry_index)
+
         except NoFactoryError as e:
             e.add_path(factory)
             raise
diff --git a/tox.ini b/tox.ini
index a96f3f3..7168c20 100644
--- a/tox.ini
+++ b/tox.ini
@@ -62,14 +62,14 @@ commands =
 
 package = editable
 
-install_command =
-    python -m pip install
-    latest: -U
 
 [testenv:unit]
 deps = -r requirements/test.txt
 commands = pytest tests/unit
 
+[testenv:latest]
+install_commands = python -m pip install -U {opts} {packages}
+
 [testenv:real_world_example]
 deps = -r examples/real_world/requirements_test.txt
 commands = pytest examples/real_world/tests/
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/frameworks-latest.yml b/.github/workflows/frameworks-latest.yml
deleted file mode 100644
index ea5e350..0000000
--- a/.github/workflows/frameworks-latest.yml
+++ /dev/null
@@ -1,37 +0,0 @@
-# This workflow will install Python dependencies, run tests
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
-
-name: TestLatest
-
-on:
-  schedule:
-    - cron: "0 0 * * *"
-
-jobs:
-  cpython:
-    runs-on: ${{ matrix.os }}
-    strategy:
-      matrix:
-        os:
-          - ubuntu-latest
-        python-version:
-          - "3.10"
-          - "3.11"
-          - "3.12"
-
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up ${{ matrix.python-version }} on ${{ matrix.os }}
-        uses: actions/setup-python@v5
-        with:
-          python-version: ${{ matrix.python-version }}
-
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          pip install . -r requirements_dev.txt
-
-      - name: Run tests
-        run: |
-          tox -e aiohttp-latest,fastapi-latest,aiogram-latest,telebot-latest,flask-latest,litestar-latest,starlette-latest,faststream-latest,arq-latest,taskiq-latest,sanic-latest
-
diff --git a/tests/unit/container/test_decorator.py b/tests/unit/container/test_decorator.py
index 120c437..3236f9b 100644
--- a/tests/unit/container/test_decorator.py
+++ b/tests/unit/container/test_decorator.py
@@ -58,6 +58,22 @@ def test_decorator():
     assert isinstance(a.a, A)
 
 
+def kwarg_decorator(x: int, *, a: A) -> A:
+    return ADecorator(a)
+
+
+def test_kwargs():
+    provider = Provider(scope=Scope.APP)
+    provider.provide(A)
+    provider.decorate(kwarg_decorator)
+    provider.provide(lambda: 1, provides=int)
+
+    container = make_container(provider)
+    a = container.get(A)
+    assert isinstance(a, ADecorator)
+    assert isinstance(a.a, A)
+
+
 def test_decorator_with_provides():
     class MyProvider(Provider):
         a = provide(A, scope=Scope.APP)
diff --git a/tests/unit/container/test_exceptions.py b/tests/unit/container/test_exceptions.py
index 973634a..9eb74b3 100644
--- a/tests/unit/container/test_exceptions.py
+++ b/tests/unit/container/test_exceptions.py
@@ -88,6 +88,34 @@ async def test_async(dep_type):
     finalizer.assert_called_once()
 
 
+def test_no_factory_cls_sync():
+    class A:
+        def __init__(self, *, x: object):
+            pass
+    provider = Provider(scope=Scope.APP)
+    provider.provide(A)
+    with pytest.raises(NoFactoryError) as e:
+        make_container(provider)
+    assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
+
+def test_invalid_type_sync():
+    class A:
+        def __init__(self, x: "B"):  # noqa: F821
+            pass
+    provider = Provider(scope=Scope.APP)
+    with pytest.raises(NameError):
+        provider.provide(A)
+
+
+def test_no_type_sync():
+    class A:
+        def __init__(self, x):
+            pass
+    provider = Provider(scope=Scope.APP)
+    with pytest.raises(ValueError):  # noqa: PT011
+        provider.provide(A)
+
+
 class MissingFactoryProvider(Provider):
     @provide(scope=Scope.APP)
     def x(self, value: object) -> int:
@@ -98,7 +126,7 @@ class MissingFactoryProvider(Provider):
         return value
 
     @provide(scope=Scope.APP)
-    def b(self, value: float) -> complex:
+    def b(self, *, value: float) -> complex:
         return value
 
 
@@ -107,6 +135,7 @@ def test_no_factory_init_sync():
         make_container(MissingFactoryProvider())
     assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
 
+
 @pytest.mark.asyncio
 async def test_no_factory_init_async():
     with pytest.raises(NoFactoryError) as e:
@@ -128,7 +157,6 @@ def test_no_factory_path_sync():
     assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
 
 
-
 @pytest.mark.asyncio
 async def test_no_factory_async():
     container = make_async_container(Provider())
@@ -137,7 +165,6 @@ async def test_no_factory_async():
     assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
 
 
-
 @pytest.mark.asyncio
 async def test_no_factory_path_async():
     container = make_async_container(
@@ -186,6 +213,7 @@ def test_missing_context_var_sync():
     with pytest.raises(NoContextValueError):
         container.get(int)
 
+
 @pytest.mark.asyncio
 async def test_missing_context_var_async():
     class MyProvider(Provider):
@@ -194,3 +222,13 @@ async def test_missing_context_var_async():
     container = make_async_container(MyProvider())
     with pytest.raises(NoContextValueError):
         await container.get(int)
+
+
+def test_no_scope():
+    class NoScopeProvider(Provider):
+        @provide
+        def x(self) -> int:
+            return 1
+
+    with pytest.raises(ValueError):  # noqa: PT011
+        NoScopeProvider()
diff --git a/tests/unit/container/test_resolve.py b/tests/unit/container/test_resolve.py
index 3954dfa..7f93aac 100644
--- a/tests/unit/container/test_resolve.py
+++ b/tests/unit/container/test_resolve.py
@@ -129,3 +129,18 @@ def test_external_method(method):
 
     container = make_container(provider)
     assert container.get(ClassA) is A_VALUE
+
+
+def kwarg_factory(a: int, /, b: float, c: complex) -> str:
+    return "ok"
+
+
+def test_kwargs():
+    provider = Provider(scope=Scope.APP)
+    provider.provide(kwarg_factory)
+    provider.provide(lambda: 1, provides=int)
+    provider.provide(lambda: 1.0, provides=float)
+    provider.provide(lambda: 1j, provides=complex)
+
+    container = make_container(provider)
+    assert container.get(str) == "ok"
diff --git a/tests/unit/sample_providers.py b/tests/unit/sample_providers.py
index 3390953..99f5cdf 100644
--- a/tests/unit/sample_providers.py
+++ b/tests/unit/sample_providers.py
@@ -68,6 +68,7 @@ value_factory = Factory(
     provides=DependencyKey(ClassA, None),
     source=A_VALUE,
     dependencies=[],
+    kw_dependencies={},
     type_=FactoryType.VALUE,
     scope=Scope.APP,
     is_to_bind=False,