Repository: reagento/dishka
Pull request #53 (2024-02-17 21:20:31)

Allow to disable cache


Patch:
diff --git a/README.md b/README.md
index ef84a44..b613a0b 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ Some of them can live while you application is running, others are destroyed and
 
   `APP` -> `REQUEST` -> `ACTION` -> `STEP`.
 
-You decide when to enter and exit them, but it is done one by one. You set a scope for your dependency when you configure how to create it. If the same dependency is requested multiple time within one scope without leaving it, then the same instance is returned.
+You decide when to enter and exit them, but it is done one by one. You set a scope for your dependency when you configure how to create it. If the same dependency is requested multiple time within one scope without leaving it, then by default the same instance is returned.
 
 If you are developing web application, you would enter `APP` scope on startup, and you would `REQUEST` scope in each HTTP-request.
 
diff --git a/docs/concepts.rst b/docs/concepts.rst
index a449f58..b2ce0aa 100644
--- a/docs/concepts.rst
+++ b/docs/concepts.rst
@@ -35,7 +35,7 @@ Standard scopes are:
 
 You decide when to enter and exit them, but it is done one by one. If you entered ``APP`` scope then the next step deeper will enter ``REQUEST`` scope.
 
-In dishka dependencies are lazy - they are created when you first request them. If the same dependency is requested multiple time within one scope then the same instance is returned. A created dependency is kept until you exit the scope. And in that moment it is not just dropped away, but corresponding finalization steps are done. You can enter same scope multiple times concurrently so to have multiple instances of objects you can work simultaneously.
+In dishka dependencies are lazy - they are created when you first request them. If the same dependency is requested multiple time within one scope then the same instance is returned (you can disable it for each dependency separately). A created dependency is kept until you exit the scope. And in that moment it is not just dropped away, but corresponding finalization steps are done. You can enter same scope multiple times concurrently so to have multiple instances of objects you can work simultaneously.
 
 Each object can depend on other objects from the same or previous scopes. So, if you have ``Config`` with scope of *APP* and ``Connection`` with scope of *REQUEST* you cannot have an *APP*-scoped object with requires a connection, but you can have *REQUEST*-scoped object which requires a ``Connection`` or a ``Config`` (or even both).
 
diff --git a/docs/provider/alias.rst b/docs/provider/alias.rst
index 370451f..4cf1d22 100644
--- a/docs/provider/alias.rst
+++ b/docs/provider/alias.rst
@@ -12,4 +12,6 @@
         def get_a(self) -> A:
             return A()
 
-        a_proto = alias(source=A, provides=AProtocol)
\ No newline at end of file
+        a_proto = alias(source=A, provides=AProtocol)
+
+Additionally, alias has own setting for caching: it caches by default regardless if source is cached. You can disable it providing ``cache=False`` argument.
\ No newline at end of file
diff --git a/docs/provider/provide.rst b/docs/provider/provide.rst
index 599ccba..e1d9bcf 100644
--- a/docs/provider/provide.rst
+++ b/docs/provider/provide.rst
@@ -8,6 +8,7 @@
 If it is used with class analyzes its ``__init__`` typehints to detect its dependencies. If it is used with method, it checks its parameters typehints and a result type. Last one describes what this method is used to create.
 
 ``scope`` argument is required to define the lifetime of the created object.
+By default the result is cached within scope. You can disable it providing ``cache=False`` argument.
 
 * For simple case add method and mark it with ``@provide`` decorator.
 
diff --git a/src/dishka/async_container.py b/src/dishka/async_container.py
index 1467903..f4272be 100644
--- a/src/dishka/async_container.py
+++ b/src/dishka/async_container.py
@@ -1,6 +1,6 @@
 from asyncio import Lock
 from dataclasses import dataclass
-from typing import Callable, List, Optional, Type, TypeVar
+from typing import Any, Callable, List, Optional, Type, TypeVar
 
 from .dependency_source import Factory, FactoryType
 from .exceptions import ExitExceptionGroup
@@ -71,7 +71,9 @@ class AsyncContainer:
             raise ValueError("No child scopes found")
         return AsyncContextWrapper(self._create_child(context, lock_factory))
 
-    async def _get_from_self(self, factory: Factory) -> T:
+    async def _get_from_self(
+            self, factory: Factory, dependency_type: Any,
+    ) -> T:
         sub_dependencies = [
             await self._get_unlocked(dependency)
             for dependency in factory.dependencies
@@ -79,19 +81,22 @@ class AsyncContainer:
         if factory.type is FactoryType.GENERATOR:
             generator = factory.source(*sub_dependencies)
             self._exits.append(Exit(factory.type, generator))
-            return next(generator)
+            solved =  next(generator)
         elif factory.type is FactoryType.ASYNC_GENERATOR:
             generator = factory.source(*sub_dependencies)
             self._exits.append(Exit(factory.type, generator))
-            return await anext(generator)
+            solved =  await anext(generator)
         elif factory.type is FactoryType.ASYNC_FACTORY:
-            return await factory.source(*sub_dependencies)
+            solved =  await factory.source(*sub_dependencies)
         elif factory.type is FactoryType.FACTORY:
-            return factory.source(*sub_dependencies)
+            solved =  factory.source(*sub_dependencies)
         elif factory.type is FactoryType.VALUE:
-            return factory.source
+            solved =  factory.source
         else:
             raise ValueError(f"Unsupported type {factory.type}")
+        if factory.cache:
+            self.context[dependency_type] = solved
+        return solved
 
     async def get(self, dependency_type: Type[T]) -> T:
         lock = self.lock
@@ -103,13 +108,12 @@ class AsyncContainer:
     async def _get_unlocked(self, dependency_type: Type[T]) -> T:
         if dependency_type in self.context:
             return self.context[dependency_type]
-        provider = self.registry.get_provider(dependency_type)
+        provider = self.registry.get_factory(dependency_type)
         if not provider:
             if not self.parent_container:
                 raise ValueError(f"No provider found for {dependency_type!r}")
             return await self.parent_container.get(dependency_type)
-        solved = await self._get_from_self(provider)
-        self.context[dependency_type] = solved
+        solved = await self._get_from_self(provider, dependency_type)
         return solved
 
     async def close(self):
diff --git a/src/dishka/container.py b/src/dishka/container.py
index 2d688a0..1460d04 100644
--- a/src/dishka/container.py
+++ b/src/dishka/container.py
@@ -1,6 +1,6 @@
 from dataclasses import dataclass
 from threading import Lock
-from typing import Callable, List, Optional, Type, TypeVar
+from typing import Any, Callable, List, Optional, Type, TypeVar
 
 from .dependency_source import Factory, FactoryType
 from .exceptions import ExitExceptionGroup
@@ -71,7 +71,9 @@ class Container:
             raise ValueError("No child scopes found")
         return ContextWrapper(self._create_child(context, lock_factory))
 
-    def _get_from_self(self, factory: Factory) -> T:
+    def _get_from_self(
+            self, factory: Factory, dependency_type: Any,
+    ) -> T:
         sub_dependencies = [
             self._get_unlocked(dependency)
             for dependency in factory.dependencies
@@ -79,13 +81,16 @@ class Container:
         if factory.type is FactoryType.GENERATOR:
             generator = factory.source(*sub_dependencies)
             self._exits.append(Exit(factory.type, generator))
-            return next(generator)
+            solved = next(generator)
         elif factory.type is FactoryType.FACTORY:
-            return factory.source(*sub_dependencies)
+            solved = factory.source(*sub_dependencies)
         elif factory.type is FactoryType.VALUE:
-            return factory.source
+            solved = factory.source
         else:
             raise ValueError(f"Unsupported type {factory.type}")
+        if factory.cache:
+            self.context[dependency_type] = solved
+        return solved
 
     def get(self, dependency_type: Type[T]) -> T:
         lock = self.lock
@@ -97,13 +102,12 @@ class Container:
     def _get_unlocked(self, dependency_type: Type[T]) -> T:
         if dependency_type in self.context:
             return self.context[dependency_type]
-        provider = self.registry.get_provider(dependency_type)
+        provider = self.registry.get_factory(dependency_type)
         if not provider:
             if not self.parent_container:
                 raise ValueError(f"No provider found for {dependency_type!r}")
             return self.parent_container.get(dependency_type)
-        solved = self._get_from_self(provider)
-        self.context[dependency_type] = solved
+        solved = self._get_from_self(provider, dependency_type)
         return solved
 
     def close(self) -> None:
diff --git a/src/dishka/dependency_source.py b/src/dishka/dependency_source.py
index 9e346c3..05aa6e2 100644
--- a/src/dishka/dependency_source.py
+++ b/src/dishka/dependency_source.py
@@ -46,7 +46,7 @@ def _identity(x: Any) -> Any:
 class Factory:
     __slots__ = (
         "dependencies", "source", "provides", "scope", "type",
-        "is_to_bound",
+        "is_to_bound", "cache",
     )
 
     def __init__(
@@ -57,6 +57,7 @@ class Factory:
             scope: BaseScope | None,
             type: FactoryType,
             is_to_bound: bool,
+            cache: bool,
     ):
         self.dependencies = dependencies
         self.source = source
@@ -64,6 +65,7 @@ class Factory:
         self.scope = scope
         self.type = type
         self.is_to_bound = is_to_bound
+        self.cache = cache
 
     def __get__(self, instance, owner):
         scope = self.scope or instance.scope
@@ -80,6 +82,7 @@ class Factory:
             scope=scope,
             type=self.type,
             is_to_bound=False,
+            cache=self.cache,
         )
 
 
@@ -101,6 +104,7 @@ def make_factory(
         provides: Any,
         scope: Optional[BaseScope],
         source: Callable,
+        cache: bool,
 ) -> Factory:
     if is_bare_generic(source):
         source = source[get_type_vars(source)]
@@ -143,6 +147,7 @@ def make_factory(
         scope=scope,
         provides=provides or possible_dependency,
         is_to_bound=is_to_bind,
+        cache=cache,
     )
 
 
@@ -151,6 +156,7 @@ def provide(
         *,
         scope: BaseScope = None,
         provides: Any = None,
+        cache: bool = True,
 ) -> Callable[[Callable], Factory]:
     ...
 
@@ -161,6 +167,7 @@ def provide(
         *,
         scope: BaseScope,
         provides: Any = None,
+        cache: bool = True,
 ) -> Factory:
     ...
 
@@ -170,6 +177,7 @@ def provide(
         *,
         scope: BaseScope | None = None,
         provides: Any = None,
+        cache: bool = True,
 ) -> Factory | Callable[[Callable], Factory]:
     """
     Mark a method or class as providing some dependency.
@@ -189,22 +197,24 @@ def provide(
     :param scope: Scope of the dependency to limit its lifetime
     :param provides: Dependency type which is provided by this factory
     :return: instance of Factory or a decorator returning it
+    :param cache: save created object to scope cache or not
     """
     if source is not None:
-        return make_factory(provides, scope, source)
+        return make_factory(provides, scope, source, cache)
 
     def scoped(func):
-        return make_factory(provides, scope, func)
+        return make_factory(provides, scope, func, cache)
 
     return scoped
 
 
 class Alias:
-    __slots__ = ("source", "provides")
+    __slots__ = ("source", "provides", "cache")
 
-    def __init__(self, source, provides):
+    def __init__(self, source, provides, cache: bool):
         self.source = source
         self.provides = provides
+        self.cache = cache
 
     def as_factory(self, scope: BaseScope) -> Factory:
         return Factory(
@@ -214,6 +224,7 @@ class Alias:
             is_to_bound=False,
             dependencies=[self.source],
             type=FactoryType.FACTORY,
+            cache=self.cache,
         )
 
     def __get__(self, instance, owner):
@@ -224,10 +235,12 @@ def alias(
         *,
         source: Type,
         provides: Type,
+        cache: bool=True,
 ) -> Alias:
     return Alias(
         source=source,
         provides=provides,
+        cache=cache,
     )
 
 
@@ -239,7 +252,7 @@ class Decorator:
         self.provides = factory.provides
 
     def as_factory(
-            self, scope: BaseScope, new_dependency: Any,
+            self, scope: BaseScope, new_dependency: Any, cache: bool,
     ) -> Factory:
         return Factory(
             scope=scope,
@@ -251,6 +264,7 @@ class Decorator:
                 for dep in self.factory.dependencies
             ],
             type=self.factory.type,
+            cache=cache,
         )
 
     def __get__(self, instance, owner):
@@ -279,10 +293,10 @@ def decorate(
         provides: Any = None,
 ) -> Decorator | Callable[[Callable], Decorator]:
     if source is not None:
-        return Decorator(make_factory(provides, None, source))
+        return Decorator(make_factory(provides, None, source, False))
 
     def scoped(func):
-        return Decorator(make_factory(provides, None, func))
+        return Decorator(make_factory(provides, None, func, False))
 
     return scoped
 
diff --git a/src/dishka/registry.py b/src/dishka/registry.py
index e59d26e..e8f1ef9 100644
--- a/src/dishka/registry.py
+++ b/src/dishka/registry.py
@@ -15,13 +15,13 @@ class Registry:
         self._factories: dict[Type, Factory] = {}
         self.scope = scope
 
-    def add_provider(self, factory: Factory):
+    def add_factory(self, factory: Factory):
         if is_generic(factory.provides):
             self._factories[get_origin(factory.provides)] = factory
         else:
             self._factories[factory.provides] = factory
 
-    def get_provider(self, dependency: Any) -> Factory | None:
+    def get_factory(self, dependency: Any) -> Factory | None:
         try:
             return self._factories[dependency]
         except KeyError:
@@ -60,6 +60,7 @@ class Registry:
             is_to_bound=factory.is_to_bound,
             type=factory.type,
             scope=factory.scope,
+            cache=factory.cache,
         )
 
 
@@ -80,7 +81,7 @@ def make_registries(
     for provider in providers:
         for source in provider.factories:
             scope = source.scope
-            registries[scope].add_provider(source)
+            registries[scope].add_factory(source)
         for source in provider.aliases:
             alias_source = source.source
             visited_types = [alias_source]
@@ -94,7 +95,7 @@ def make_registries(
             scope = dep_scopes[alias_source]
             dep_scopes[source.provides] = scope
             source = source.as_factory(scope)
-            registries[scope].add_provider(source)
+            registries[scope].add_factory(source)
         for source in provider.decorators:
             provides = source.provides
             scope = dep_scopes[provides]
@@ -104,12 +105,12 @@ def make_registries(
                 source.provides,
             )
             decorator_depth[provides] += 1
-            old_provider = registry.get_provider(provides)
-            old_provider.provides = undecorated_type
-            registry.add_provider(old_provider)
+            old_factory = registry.get_factory(provides)
+            old_factory.provides = undecorated_type
+            registry.add_factory(old_factory)
             source = source.as_factory(
-                scope, undecorated_type,
+                scope, undecorated_type, old_factory.cache,
             )
-            registries[scope].add_provider(source)
+            registries[scope].add_factory(source)
 
     return list(registries.values())


Test patch:
diff --git a/tests/unit/container/test_cache.py b/tests/unit/container/test_cache.py
index 32e26d6..5170c1a 100644
--- a/tests/unit/container/test_cache.py
+++ b/tests/unit/container/test_cache.py
@@ -46,18 +46,49 @@ async def test_cache_async():
             assert await state.get(int) == 2
             assert await state.get(int) == 2
 
+def test_nocache_sync():
+    class MyProvider(Provider):
+        value = 0
+
+        @provide(scope=Scope.REQUEST, cache=False)
+        def get_int(self) -> int:
+            self.value += 1
+            return self.value
+
+    with make_container(MyProvider()) as container:
+        with container() as state:
+            assert state.get(int) == 1
+            assert state.get(int) == 2
+
+
+@pytest.mark.asyncio
+async def test_nocache_async():
+    class MyProvider(Provider):
+        value = 0
+
+        @provide(scope=Scope.REQUEST, cache=False)
+        async def get_int(self) -> int:
+            self.value += 1
+            return self.value
+
+    async with make_async_container(MyProvider()) as container:
+        async with container() as state:
+            assert await state.get(int) == 1
+            assert await state.get(int) == 2
+
 
 @pytest.fixture()
 def alias_provider():
     class MyProvider(Provider):
         value = 0
 
-        @provide(scope=Scope.APP)
+        @provide(scope=Scope.APP, cache=False)
         def get_int(self) -> int:
             self.value += 1
             return self.value
 
         float = alias(source=int, provides=float)
+        complex = alias(source=int, provides=complex, cache=False)
 
     return MyProvider()
 
@@ -65,11 +96,17 @@ def alias_provider():
 def test_alias_sync(alias_provider):
     with make_container(alias_provider) as container:
         assert container.get(int) == 1
-        assert container.get(float) == 1
+        assert container.get(float) == 2
+        assert container.get(float) == 2
+        assert container.get(complex) == 3
+        assert container.get(complex) == 4
 
 
 @pytest.mark.asyncio
 async def test_alias_async(alias_provider):
     async with make_async_container(alias_provider) as container:
         assert await container.get(int) == 1
-        assert await container.get(float) == 1
+        assert await container.get(float) == 2
+        assert await container.get(float) == 2
+        assert await container.get(complex) == 3
+        assert await container.get(complex) == 4
diff --git a/tests/unit/sample_providers.py b/tests/unit/sample_providers.py
index 2b29221..56791d7 100644
--- a/tests/unit/sample_providers.py
+++ b/tests/unit/sample_providers.py
@@ -50,4 +50,5 @@ value_factory = Factory(
     type=FactoryType.VALUE,
     scope=Scope.APP,
     is_to_bound=False,
+    cache=False,
 )