Repository: reagento/dishka
Pull request #109 (2024-03-24 23:45:22)

Skipped scopes


Patch:
diff --git a/README.md b/README.md
index 7053e9d..a7dbcc7 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,7 @@ setup_dishka(container, app)
 **Dependency** is what you need for some part of your code to work. They are just object which you do not create in place and probably want to replace some day. At least for tests.
 Some of them can live while you application is running, others are destroyed and created on each request. Dependencies can depend on other objects, which are their dependencies.
 
-**Scope** is a lifespan of a dependency. Standard scopes are:
+**Scope** is a lifespan of a dependency. Standard scopes are (without skipped ones):
 
   `APP` -> `REQUEST` -> `ACTION` -> `STEP`.
 
diff --git a/docs/advanced/scopes.rst b/docs/advanced/scopes.rst
new file mode 100644
index 0000000..9cc23b6
--- /dev/null
+++ b/docs/advanced/scopes.rst
@@ -0,0 +1,82 @@
+.. include:: <isonum.txt>
+
+Scope management
+*************************
+
+In dishka scope determines a lifespan of dependency. Firstly, when creating provider, your attach dependency to its scope. Then, when you use container you enter scope and dependency is retained once it is requested until you exist that scope.
+
+The set of scopes is defined once per container and providers should use the same scopes. You are not limited to standard scopes and can create custom ones, but it is hardly ever needed.
+
+In most cases you need only 2 scopes. ``APP``-scope is usually entered on container creation and ``REQUEST``-scope is the on you go into during some event processing:
+
+    ``APP`` |rarr| ``REQUEST``
+
+But standard set is much wider. Dishka supported *skipped* scopes and has just 2 additional just in case:
+
+    ``[RUNTIME]`` |rarr| ``APP`` |rarr| ``[SESSION]`` |rarr| ``REQUEST`` |rarr| ``ACTION`` |rarr| ``STEP``
+
+Entering container context will bring you to the next non-skipped scope. Or to the target scope if you provided one.
+
+Skipped scopes
+======================
+
+Skipped scope in the scope which is implicitly passed through when you are going deeper from parent container.
+
+
+For example, standard scopes ``RUNTIME`` and ``SESSION`` are marked as ``skip=True``:
+
+When you create a container, you implicitly enter ``APP``-scope. ``RUNTIME`` scope is still used, you can have dependencies attached to it, but it is entered automatically and also closed once you close ``APP``-scoped container.
+
+.. code-block:: python
+
+    container = make_container(provider)  # APP
+
+In other case you can enter ``RUNTIME`` scope passing ``start_scope=Scope.RUNTIME`` when entering container and you will get that scope. When you go deeper you will enter ``APP`` scope as it is the next appropriate one. In that case, closing ``APP`` scope won't close ``RUNTIME`` as it was entered explicitly.
+
+.. code-block:: python
+
+    container = make_container(provider, start_scope=Scope.RUNTIME)
+    with container() as app_container:
+        # RUNTIME -> APP
+
+The same thing is about going into when you are in ``APP`` scope. If you just call ``with container()`` you will skip ``SESSION`` scope and go into ``REQUEST`` one. Both will be closed simultaneously. Calling ``with container(scope=Scope.SESSION)`` will bring you to that scope and you can go into ``REQUEST`` with the next call.
+
+
+.. code-block:: python
+
+    container = make_container(provider)
+    with container() as request_container:
+        # APP -> Session -> REQUEST
+        pass
+
+    with container(scope=Scope.SESSION) as session_container:
+        # APP -> Session
+        with session_container() as request_container:
+            # Session -> REQUEST
+            pass
+
+.. note::
+
+    * ``RUNTIME`` scope can be useful for adding dependencies which are kept between tests recreating apps.
+    * ``SESSION`` scope can be useful for connection related objects in websockets, while HTTP-request handler goes straight into ``REQUEST`` scope.
+
+Custom scopes
+======================
+
+To create a custom scopes set you need
+
+* inherit from ``BaseScope``
+* set scopes using ``new_scope()``
+* provide that scope class to ``make_container`` call as a ``scopes=`` argument
+
+.. code-block:: python
+
+    from dishka import BaseScope, Provider, make_container, new_scope
+
+    class MyScope(BaseScope):
+        APPLICATION = new_scope("APPLICATION")
+        SESSION = new_scope("SESSION", skip=True)
+        EVENT = new_scope("EVENT")
+
+    provider = Provider(scope=MyScope.EVENT)
+    make_container(provider, scopes=MyScope)
\ No newline at end of file
diff --git a/docs/concepts.rst b/docs/concepts.rst
index edabcb1..70327c1 100644
--- a/docs/concepts.rst
+++ b/docs/concepts.rst
@@ -29,7 +29,7 @@ Scope
 **Scope** is a lifespan of a dependency.
 Some dependencies can live while you application is running, others are created and destroyed on each request. In more rare cases you need more short-lived objects. You set a scope for your dependency when you configure how to create it.
 
-Standard scopes are:
+Standard scopes are (excluding skipped):
 
     ``APP`` |rarr| ``REQUEST`` |rarr| ``ACTION`` |rarr| ``STEP``
 
diff --git a/docs/index.rst b/docs/index.rst
index 4cde0b2..86c0683 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -16,6 +16,7 @@ This library is targeting to provide only an IoC-container but make it really us
    container/index
    integrations/index
    advanced/components
+   advanced/scopes
    advanced/testing/index
    alternatives
    contributing
diff --git a/src/dishka/__init__.py b/src/dishka/__init__.py
index 6e83444..6e0229d 100644
--- a/src/dishka/__init__.py
+++ b/src/dishka/__init__.py
@@ -6,7 +6,7 @@ __all__ = [
     "Provider",
     "alias", "decorate", "from_context", "provide", "DependencyKey",
     "FromDishka",
-    "BaseScope", "Scope",
+    "BaseScope", "Scope", "new_scope",
 ]
 
 from .async_container import AsyncContainer, make_async_container
@@ -21,5 +21,5 @@ from .entities.component import DEFAULT_COMPONENT, Component
 from .entities.depends_marker import FromDishka
 from .entities.key import DependencyKey, FromComponent
 from .entities.provides_marker import AnyOf
-from .entities.scope import BaseScope, Scope
+from .entities.scope import BaseScope, Scope, new_scope
 from .provider import Provider
diff --git a/src/dishka/async_container.py b/src/dishka/async_container.py
index 508a819..9e2af07 100644
--- a/src/dishka/async_container.py
+++ b/src/dishka/async_container.py
@@ -20,7 +20,7 @@ T = TypeVar("T")
 class AsyncContainer:
     __slots__ = (
         "registry", "child_registries", "context", "parent_container",
-        "lock", "_exits",
+        "lock", "_exits", "close_parent",
     )
 
     def __init__(
@@ -30,6 +30,7 @@ class AsyncContainer:
             parent_container: Optional["AsyncContainer"] = None,
             context: dict | None = None,
             lock_factory: Callable[[], Lock] | None = None,
+            close_parent: bool = False,
     ):
         self.registry = registry
         self.child_registries = child_registries
@@ -47,33 +48,54 @@ class AsyncContainer:
         else:
             self.lock = None
         self._exits: list[Exit] = []
-
-    def _create_child(
-            self,
-            context: dict | None,
-            lock_factory: Callable[[], Lock] | None,
-    ) -> "AsyncContainer":
-        return AsyncContainer(
-            *self.child_registries,
-            parent_container=self,
-            context=context,
-            lock_factory=lock_factory,
-        )
+        self.close_parent = close_parent
 
     def __call__(
             self,
             context: dict | None = None,
             lock_factory: Callable[[], Lock] | None = None,
+            scope: BaseScope | None = None,
     ) -> "AsyncContextWrapper":
         """
         Prepare container for entering the inner scope.
         :param context: Data which will available in inner scope
         :param lock_factory: Callable to create lock instance or None
+        :param scope: target scope or None to enter next non-skipped scope
         :return: async context manager for inner scope
         """
         if not self.child_registries:
             raise ValueError("No child scopes found")
-        return AsyncContextWrapper(self._create_child(context, lock_factory))
+
+        child = AsyncContainer(
+            *self.child_registries,
+            parent_container=self,
+            context=context,
+            lock_factory=lock_factory,
+        )
+        if scope is None:
+            while child.registry.scope.skip:
+                if not child.child_registries:
+                    raise ValueError("No non-skipped scopes found.")
+                child = AsyncContainer(
+                    *child.child_registries,
+                    parent_container=child,
+                    context=context,
+                    lock_factory=lock_factory,
+                    close_parent=True,
+                )
+        else:
+            while child.registry.scope is not scope:
+                if not child.child_registries:
+                    raise ValueError(f"Cannot find {scope} as a child of "
+                                     f"current {self.registry.scope}")
+                child = AsyncContainer(
+                    *child.child_registries,
+                    parent_container=child,
+                    context=context,
+                    lock_factory=lock_factory,
+                    close_parent=True,
+                )
+        return AsyncContextWrapper(child)
 
     async def get(
             self,
@@ -118,6 +140,11 @@ class AsyncContainer:
                 pass
             except Exception as err:  # noqa: BLE001
                 errors.append(err)
+        if self.close_parent:
+            try:
+                await self.parent_container.close()
+            except Exception as err:  # noqa: BLE001
+                errors.append(err)
         if errors:
             raise ExitError("Cleanup context errors", errors)
 
@@ -139,6 +166,7 @@ def make_async_container(
         context: dict | None = None,
         lock_factory: Callable[[], Lock] | None = Lock,
         skip_validation: bool = False,
+        start_scope: BaseScope | None = None,
 ) -> AsyncContainer:
     registries = RegistryBuilder(
         scopes=scopes,
@@ -146,8 +174,28 @@ def make_async_container(
         providers=providers,
         skip_validation=skip_validation,
     ).build()
-    return AsyncContainer(
+    container = AsyncContainer(
         *registries,
         context=context,
         lock_factory=lock_factory,
     )
+
+    if start_scope is None:
+        while container.registry.scope.skip:
+            container = AsyncContainer(
+                *container.child_registries,
+                parent_container=container,
+                context=context,
+                lock_factory=lock_factory,
+                close_parent=True,
+            )
+    else:
+        while container.registry.scope is not start_scope:
+            container = AsyncContainer(
+                *container.child_registries,
+                parent_container=container,
+                context=context,
+                lock_factory=lock_factory,
+                close_parent=True,
+            )
+    return container
diff --git a/src/dishka/container.py b/src/dishka/container.py
index 34eb0a9..2952673 100644
--- a/src/dishka/container.py
+++ b/src/dishka/container.py
@@ -20,7 +20,7 @@ T = TypeVar("T")
 class Container:
     __slots__ = (
         "registry", "child_registries", "context", "parent_container",
-        "lock", "_exits",
+        "lock", "_exits", "close_parent",
     )
 
     def __init__(
@@ -30,6 +30,7 @@ class Container:
             parent_container: Optional["Container"] = None,
             context: dict | None = None,
             lock_factory: Callable[[], Lock] | None = None,
+            close_parent: bool = False,
     ):
         self.registry = registry
         self.child_registries = child_registries
@@ -46,33 +47,53 @@ class Container:
         else:
             self.lock = None
         self._exits: list[Exit] = []
-
-    def _create_child(
-            self,
-            context: dict | None,
-            lock_factory: Callable[[], Lock] | None,
-    ) -> "Container":
-        return Container(
-            *self.child_registries,
-            parent_container=self,
-            context=context,
-            lock_factory=lock_factory,
-        )
+        self.close_parent = close_parent
 
     def __call__(
             self,
             context: dict | None = None,
             lock_factory: Callable[[], Lock] | None = None,
+            scope: BaseScope | None = None,
     ) -> "ContextWrapper":
         """
         Prepare container for entering the inner scope.
         :param context: Data which will available in inner scope
         :param lock_factory: Callable to create lock instance or None
+        :param scope: target scope or None to enter next non-skipped scope
         :return: context manager for inner scope
         """
         if not self.child_registries:
             raise ValueError("No child scopes found")
-        return ContextWrapper(self._create_child(context, lock_factory))
+        child = Container(
+            *self.child_registries,
+            parent_container=self,
+            context=context,
+            lock_factory=lock_factory,
+        )
+        if scope is None:
+            while child.registry.scope.skip:
+                if not child.child_registries:
+                    raise ValueError("No non-skipped scopes found.")
+                child = Container(
+                    *child.child_registries,
+                    parent_container=child,
+                    context=context,
+                    lock_factory=lock_factory,
+                    close_parent=True,
+                )
+        else:
+            while child.registry.scope is not scope:
+                if not child.child_registries:
+                    raise ValueError(f"Cannot find {scope} as a child of "
+                                     f"current {self.registry.scope}")
+                child = Container(
+                    *child.child_registries,
+                    parent_container=child,
+                    context=context,
+                    lock_factory=lock_factory,
+                    close_parent=True,
+                )
+        return ContextWrapper(child)
 
     def get(
             self,
@@ -112,6 +133,12 @@ class Container:
                 pass
             except Exception as err:  # noqa: BLE001
                 errors.append(err)
+        if self.close_parent:
+            try:
+                self.parent_container.close()
+            except Exception as err:  # noqa: BLE001
+                errors.append(err)
+
         if errors:
             raise ExitError("Cleanup context errors", errors)
 
@@ -135,6 +162,7 @@ def make_container(
         context: dict | None = None,
         lock_factory: Callable[[], Lock] | None = None,
         skip_validation: bool = False,
+        start_scope: BaseScope | None = None,
 ) -> Container:
     registries = RegistryBuilder(
         scopes=scopes,
@@ -142,4 +170,27 @@ def make_container(
         providers=providers,
         skip_validation=skip_validation,
     ).build()
-    return Container(*registries, context=context, lock_factory=lock_factory)
+    container = Container(
+        *registries,
+        context=context,
+        lock_factory=lock_factory,
+    )
+    if start_scope is None:
+        while container.registry.scope.skip:
+            container = Container(
+                *container.child_registries,
+                parent_container=container,
+                context=context,
+                lock_factory=lock_factory,
+                close_parent=True,
+            )
+    else:
+        while container.registry.scope is not start_scope:
+            container = Container(
+                *container.child_registries,
+                parent_container=container,
+                context=context,
+                lock_factory=lock_factory,
+                close_parent=True,
+            )
+    return container
diff --git a/src/dishka/entities/scope.py b/src/dishka/entities/scope.py
index 23d52c1..296e76e 100644
--- a/src/dishka/entities/scope.py
+++ b/src/dishka/entities/scope.py
@@ -1,19 +1,36 @@
+from dataclasses import dataclass
 from enum import Enum
 
 
+@dataclass(slots=True)
+class _ScopeValue:
+    name: str
+    skip: bool
+
+
+def new_scope(value: str, *, skip: bool = False) -> _ScopeValue:
+    return _ScopeValue(value, skip)
+
+
 class BaseScope(Enum):
-    pass
+    __slots__ = ("name", "skip")
+
+    def __init__(self, value: _ScopeValue):
+        self.name = value.name
+        self.skip = value.skip
 
 
 class Scope(BaseScope):
-    APP = "APP"
-    REQUEST = "REQUEST"
-    ACTION = "ACTION"
-    STEP = "STEP"
+    RUNTIME = new_scope("RUNTIME", skip=True)
+    APP = new_scope("APP")
+    SESSION = new_scope("SESSION", skip=True)
+    REQUEST = new_scope("REQUEST")
+    ACTION = new_scope("ACTION")
+    STEP = new_scope("STEP")
 
 
 class InvalidScopes(BaseScope):
-    UNKNOWN_SCOPE = "<unknown scope>"
+    UNKNOWN_SCOPE = new_scope("<unknown scope>", skip=True)
 
     def __str__(self):
         return self.value


Test patch:
diff --git a/tests/unit/container/test_enter_exit.py b/tests/unit/container/test_enter_exit.py
new file mode 100644
index 0000000..ce78b28
--- /dev/null
+++ b/tests/unit/container/test_enter_exit.py
@@ -0,0 +1,147 @@
+import pytest
+
+from dishka import (
+    Provider,
+    Scope,
+    make_async_container,
+    make_container,
+    provide,
+)
+from ..sample_providers import (
+    ClassA,
+    sync_gen_a,
+)
+
+
+@pytest.mark.parametrize(
+    ("provide_scope", "start_scope", "expected_scope"),
+    [
+        (Scope.RUNTIME, Scope.APP, Scope.APP),
+        (Scope.APP, Scope.APP, Scope.APP),
+        (Scope.APP, None, Scope.APP),
+    ],
+)
+def test_sync_create(provide_scope, start_scope, expected_scope):
+    class MyProvider(Provider):
+        a = provide(sync_gen_a, scope=provide_scope)
+
+        @provide(scope=provide_scope)
+        def get_int(self) -> int:
+            return 100
+
+    container = make_container(MyProvider(), start_scope=start_scope)
+    assert container.registry.scope is expected_scope
+    a = container.get(ClassA)
+    assert a
+    assert a.dep == 100
+    container.close()
+    assert a.closed
+
+
+@pytest.mark.parametrize(
+    ("provide_scope", "start_scope", "expected_scope"),
+    [
+        (Scope.RUNTIME, Scope.APP, Scope.APP),
+        (Scope.APP, Scope.APP, Scope.APP),
+        (Scope.APP, None, Scope.APP),
+    ],
+)
+@pytest.mark.asyncio
+async def test_async_create(provide_scope, start_scope, expected_scope):
+    class MyProvider(Provider):
+        a = provide(sync_gen_a, scope=provide_scope)
+
+        @provide(scope=provide_scope)
+        def get_int(self) -> int:
+            return 100
+
+    container = make_async_container(MyProvider(), start_scope=start_scope)
+    assert container.registry.scope is expected_scope
+    a = await container.get(ClassA)
+    assert a
+    assert a.dep == 100
+    await container.close()
+    assert a.closed
+
+
+@pytest.mark.parametrize(
+    ("provide_scope", "start_scope", "expected_scope"),
+    [
+        (Scope.SESSION, Scope.REQUEST, Scope.REQUEST),
+        (Scope.REQUEST, Scope.REQUEST, Scope.REQUEST),
+        (Scope.REQUEST, Scope.STEP, Scope.STEP),
+        (Scope.REQUEST, None, Scope.REQUEST),
+    ],
+)
+def test_sync_enter(provide_scope, start_scope, expected_scope):
+    class MyProvider(Provider):
+        a = provide(sync_gen_a, scope=provide_scope)
+
+        @provide(scope=provide_scope)
+        def get_int(self) -> int:
+            return 100
+
+    base_container = make_container(MyProvider())
+    with base_container(scope=start_scope) as container:
+        assert container.registry.scope is expected_scope
+        a = container.get(ClassA)
+        assert a
+        assert a.dep == 100
+    assert a.closed
+
+
+@pytest.mark.parametrize(
+    ("provide_scope", "start_scope", "expected_scope"),
+    [
+        (Scope.SESSION, Scope.REQUEST, Scope.REQUEST),
+        (Scope.REQUEST, Scope.REQUEST, Scope.REQUEST),
+        (Scope.REQUEST, Scope.STEP, Scope.STEP),
+        (Scope.REQUEST, None, Scope.REQUEST),
+    ],
+)
+@pytest.mark.asyncio
+async def test_async_enter(provide_scope, start_scope, expected_scope):
+    class MyProvider(Provider):
+        a = provide(sync_gen_a, scope=provide_scope)
+
+        @provide(scope=provide_scope)
+        def get_int(self) -> int:
+            return 100
+
+    base_container = make_async_container(MyProvider())
+    async with base_container(scope=start_scope) as container:
+        assert container.registry.scope is expected_scope
+        a = await container.get(ClassA)
+        assert a
+        assert a.dep == 100
+    assert a.closed
+
+
+def test_sync_exit_one():
+    class MyProvider(Provider):
+        a = provide(sync_gen_a, scope=Scope.RUNTIME)
+
+        @provide(scope=Scope.RUNTIME)
+        def get_int(self) -> int:
+            return 100
+
+    base_container = make_container(MyProvider(), start_scope=Scope.RUNTIME)
+    with base_container() as container:
+        a = container.get(ClassA)
+    assert not a.closed
+
+
+@pytest.mark.asyncio
+async def test_async_exit_one():
+    class MyProvider(Provider):
+        a = provide(sync_gen_a, scope=Scope.RUNTIME)
+
+        @provide(scope=Scope.RUNTIME)
+        def get_int(self) -> int:
+            return 100
+
+    base_container = make_async_container(MyProvider(),
+                                          start_scope=Scope.RUNTIME)
+    async with base_container() as container:
+        a = await container.get(ClassA)
+    assert not a.closed
diff --git a/tests/unit/container/test_resolve.py b/tests/unit/container/test_resolve.py
index 8b3aef3..3954dfa 100644
--- a/tests/unit/container/test_resolve.py
+++ b/tests/unit/container/test_resolve.py
@@ -38,6 +38,7 @@ def test_sync(factory, closed):
             return 100
 
     container = make_container(MyProvider())
+    assert container.registry.scope is Scope.APP
     a = container.get(ClassA)
     assert a
     assert a.dep == 100
@@ -67,6 +68,7 @@ async def test_async(factory, closed):
             return 100
 
     container = make_async_container(MyProvider())
+    assert container.registry.scope is Scope.APP
     a = await container.get(ClassA)
     assert a
     assert a.dep == 100