Repository: reagento/dishka
Pull request #85 (2024-02-28 23:26:50)

Graph validation


Patch:
diff --git a/README.md b/README.md
index 9b07a69..9b13b50 100644
--- a/README.md
+++ b/README.md
@@ -218,10 +218,22 @@ container = make_async_container(MyProvider())
 a = await container.get(A)
 ```
 
-* Having some data connected with scope which you want to use when solving dependencies? Set it when entering scope. These classes can be used as parameters of your `provide` methods
+* Having some data connected with scope which you want to use when solving dependencies? Set it when entering scope. These classes can be used as parameters of your `provide` methods. But you need to specify them in provider as retrieved form context.
 
 ```python
-container = make_async_container(MyProvider(), context={App: app})
+from dishka import from_context, Provider, provide, Scope
+
+class MyProvider(Provider):
+    scope = Scope.REQUEST
+
+    app = from_context(provides=App, scope=Scope.APP)
+    request = from_context(provides=RequestClass)
+
+    @provide
+    async def get_a(self, request: RequestClass, app: App) -> A:
+        ...
+
+container = make_container(MyProvider(), context={App: app})
 with container(context={RequestClass: request_instance}) as request_container:
     pass
 ```
diff --git a/docs/concepts.rst b/docs/concepts.rst
index f833275..edabcb1 100644
--- a/docs/concepts.rst
+++ b/docs/concepts.rst
@@ -88,7 +88,8 @@ There are 3 special functions:
 
 * ``@provide`` is used to declare a factory providing a dependency. It can be used with some class or as a method decorator. :ref:`Read more<provide>`
 * ``alias`` is used to allow retrieving of the same object by different type hints. :ref:`Read more<alias>`
-* ``decorate`` is used to modify or wrap an object which is already configured in another ``Provider``. :ref:`Read more<decorate>`
+* ``from_context`` is used to mark a dependency as context data, which will be set manually when entering a scope. :ref:`Read more<from-context>`
+* ``@decorate`` is used to modify or wrap an object which is already configured in another ``Provider``. :ref:`Read more<decorate>`
 
 Component
 ====================
diff --git a/docs/container/index.rst b/docs/container/index.rst
index e450018..b6cd7d8 100644
--- a/docs/container/index.rst
+++ b/docs/container/index.rst
@@ -107,13 +107,15 @@ To prevent such a condition you need to protect any session whose children can b
 Context data
 ====================
 
-Often, your scopes are assigned with some external events: HTTP-requests, message from queue, callbacks from framework. You can use those objects when creating dependencies. The difference from normal factories is that they are not created inside some ``Provider``, but passed to the scope:
+Often, your scopes are assigned with some external events: HTTP-requests, message from queue, callbacks from framework. You can use those objects when creating dependencies. The difference from normal factories is that they are not created inside some ``Provider``, but passed to the scope. You need explicitly tell dishka which dependencies are expected to be received from context using ``from_context``
 
 .. code-block:: python
 
     from framework import Request
 
     class MyProvider:
+        request = from_context(provides=Request, scope=Scope.REQUEST)
+
         @provide(scope=Scope.REQUEST)
         def a(self, request: Request) -> A:
             return A(data=request.contents)
diff --git a/docs/provider/from_context.rst b/docs/provider/from_context.rst
new file mode 100644
index 0000000..03f7006
--- /dev/null
+++ b/docs/provider/from_context.rst
@@ -0,0 +1,25 @@
+.. _from-context:
+
+@from_context
+****************
+
+You can put some data manually when entering scope and rely on it in your provider factories. To make it work you need to mark a dependency as retrieved from context using ``from_context`` and the use it as usual. Later, set ``context=`` argument when you enter corresponding scope.
+
+
+.. code-block:: python
+
+    from dishka import from_context, Provider, provide, Scope
+
+    class MyProvider(Provider):
+        scope = Scope.REQUEST
+
+        app = from_context(provides=App, scope=Scope.APP)
+        request = from_context(provides=RequestClass)
+
+        @provide
+        async def get_a(self, request: RequestClass, app: App) -> A:
+            ...
+
+    container = make_container(MyProvider(), context={App: app})
+    with container(context={RequestClass: request_instance}) as request_container:
+        pass
diff --git a/docs/provider/index.rst b/docs/provider/index.rst
index 7715df4..5c13b18 100644
--- a/docs/provider/index.rst
+++ b/docs/provider/index.rst
@@ -101,4 +101,5 @@ Though it is a normal object, not all attributes are analyzed by ``Container``,
 
    provide
    alias
+   from_context
    decorate
diff --git a/examples/context_val.py b/examples/context_val.py
new file mode 100644
index 0000000..2aedabb
--- /dev/null
+++ b/examples/context_val.py
@@ -0,0 +1,38 @@
+from typing import Annotated
+
+from dishka import Provider, provide, make_container, Scope, FromComponent, \
+    Container
+
+
+class A:
+    def __init__(self, a: int) -> None:
+        pass
+
+
+class Provider1(Provider):
+    scope = Scope.APP
+
+    @provide
+    def a1(self, a: float) -> int: ...
+
+    @provide
+    def a2(self, a: bool) -> float: ...
+
+    @provide
+    def a3(self,
+           a: Annotated[complex, FromComponent("provider2")]) -> bool: ...
+
+    @provide
+    def a4(self, a: A) -> complex: ...
+
+
+class Provider2(Provider):
+    scope = Scope.APP
+    component = "provider2"
+
+    @provide
+    def a1(self, a: Container) -> complex: ...
+
+
+c = make_container(Provider1(), Provider2())
+c.get(type)
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 15ece77..4a2edba 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,2 +1,2 @@
-ruff==0.2.*
+ruff==0.3.*
 tox==4.12.*
diff --git a/src/dishka/__init__.py b/src/dishka/__init__.py
index b51183b..72896df 100644
--- a/src/dishka/__init__.py
+++ b/src/dishka/__init__.py
@@ -3,7 +3,7 @@ __all__ = [
     "DEFAULT_COMPONENT", "Component",
     "make_container", "Container", "FromComponent",
     "Provider",
-    "alias", "decorate", "provide", "DependencyKey",
+    "alias", "decorate", "from_context", "provide", "DependencyKey",
     "BaseScope", "Scope",
 ]
 
@@ -12,6 +12,7 @@ from .container import Container, make_container
 from .dependency_source import (
     alias,
     decorate,
+    from_context,
     provide,
 )
 from .entities.component import DEFAULT_COMPONENT, Component
diff --git a/src/dishka/async_container.py b/src/dishka/async_container.py
index 647408f..8926e6f 100644
--- a/src/dishka/async_container.py
+++ b/src/dishka/async_container.py
@@ -9,11 +9,12 @@ from dishka.entities.scope import BaseScope, Scope
 from .dependency_source import Factory, FactoryType
 from .exceptions import (
     ExitError,
+    NoContextValueError,
     NoFactoryError,
     UnsupportedFactoryError,
 )
 from .provider import Provider
-from .registry import Registry, make_registries
+from .registry import Registry, RegistryBuilder
 
 T = TypeVar("T")
 
@@ -92,7 +93,7 @@ class AsyncContainer:
                 for dependency in factory.dependencies
             ]
         except NoFactoryError as e:
-            e.add_path(key)
+            e.add_path(factory)
             raise
 
         if factory.type is FactoryType.GENERATOR:
@@ -109,6 +110,13 @@ class AsyncContainer:
             solved = await factory.source(*sub_dependencies)
         elif factory.type is FactoryType.VALUE:
             solved = factory.source
+        elif factory.type is FactoryType.ALIAS:
+            solved = sub_dependencies[0]
+        elif factory.type is FactoryType.CONTEXT:
+            raise NoContextValueError(
+                f"Value for type {factory.provides.type_hint} is not found "
+                f"in container context with scope={factory.scope}",
+            )
         else:
             raise UnsupportedFactoryError(
                 f"Unsupported factory type {factory.type}.",
@@ -175,8 +183,14 @@ def make_async_container(
         scopes: type[BaseScope] = Scope,
         context: dict | None = None,
         lock_factory: Callable[[], Lock] | None = Lock,
+        skip_validation: bool = False,
 ) -> AsyncContainer:
-    registries = make_registries(*providers, scopes=scopes)
+    registries = RegistryBuilder(
+        scopes=scopes,
+        container_type=AsyncContainer,
+        providers=providers,
+        skip_validation=skip_validation,
+    ).build()
     return AsyncContainer(
         *registries,
         context=context,
diff --git a/src/dishka/container.py b/src/dishka/container.py
index 6c8dd88..06c7bb4 100644
--- a/src/dishka/container.py
+++ b/src/dishka/container.py
@@ -9,11 +9,12 @@ from dishka.entities.scope import BaseScope, Scope
 from .dependency_source import Factory, FactoryType
 from .exceptions import (
     ExitError,
+    NoContextValueError,
     NoFactoryError,
     UnsupportedFactoryError,
 )
 from .provider import Provider
-from .registry import Registry, make_registries
+from .registry import Registry, RegistryBuilder
 
 T = TypeVar("T")
 
@@ -91,7 +92,7 @@ class Container:
                 for dependency in factory.dependencies
             ]
         except NoFactoryError as e:
-            e.add_path(key)
+            e.add_path(factory)
             raise
 
         if factory.type is FactoryType.GENERATOR:
@@ -112,6 +113,13 @@ class Container:
             )
         elif factory.type is FactoryType.VALUE:
             solved = factory.source
+        elif factory.type is FactoryType.ALIAS:
+            solved = sub_dependencies[0]
+        elif factory.type is FactoryType.CONTEXT:
+            raise NoContextValueError(
+                f"Value for type {factory.provides.type_hint} is not found "
+                f"in container context with scope={factory.scope}",
+            )
         else:
             raise UnsupportedFactoryError(
                 f"Unsupported factory type {factory.type}. ",
@@ -176,6 +184,12 @@ def make_container(
         scopes: type[BaseScope] = Scope,
         context: dict | None = None,
         lock_factory: Callable[[], Lock] | None = None,
+        skip_validation: bool = False,
 ) -> Container:
-    registries = make_registries(*providers, scopes=scopes)
+    registries = RegistryBuilder(
+        scopes=scopes,
+        container_type=Container,
+        providers=providers,
+        skip_validation=skip_validation,
+    ).build()
     return Container(*registries, context=context, lock_factory=lock_factory)
diff --git a/src/dishka/dependency_source/__init__.py b/src/dishka/dependency_source/__init__.py
index d206f83..b419e66 100644
--- a/src/dishka/dependency_source/__init__.py
+++ b/src/dishka/dependency_source/__init__.py
@@ -1,12 +1,14 @@
 __all__ = [
     "DependencySource",
     "alias", "Alias",
+    "context_var", "from_context",
     "decorate", "Decorator",
     "provide", "Factory", "FactoryType",
 ]
 
 from .alias import Alias, alias
+from .context_var import ContextVariable, from_context
 from .decorator import Decorator, decorate
 from .factory import Factory, FactoryType, provide
 
-DependencySource = Alias | Factory | Decorator
+DependencySource = Alias | Factory | Decorator | ContextVariable
diff --git a/src/dishka/dependency_source/alias.py b/src/dishka/dependency_source/alias.py
index f28c6a8..1ed651e 100644
--- a/src/dishka/dependency_source/alias.py
+++ b/src/dishka/dependency_source/alias.py
@@ -34,7 +34,7 @@ class Alias:
             provides=self.provides.with_component(component),
             is_to_bind=False,
             dependencies=[self.source.with_component(component)],
-            type_=FactoryType.FACTORY,
+            type_=FactoryType.ALIAS,
             cache=self.cache,
         )
 
diff --git a/src/dishka/dependency_source/context_var.py b/src/dishka/dependency_source/context_var.py
new file mode 100644
index 0000000..59914e7
--- /dev/null
+++ b/src/dishka/dependency_source/context_var.py
@@ -0,0 +1,64 @@
+from typing import (
+    Any,
+)
+
+from dishka.entities.component import DEFAULT_COMPONENT, Component
+from dishka.entities.key import DependencyKey
+from dishka.entities.scope import BaseScope
+from .alias import alias
+from .factory import Factory, FactoryType
+
+
+def _context_stub() -> Any:
+    raise NotImplementedError
+
+
+class ContextVariable:
+    __slots__ = ("provides", "scope")
+
+    def __init__(
+            self, *,
+            provides: DependencyKey,
+            scope: BaseScope,
+    ) -> None:
+        self.provides = provides
+        self.scope = scope
+
+    def as_factory(
+            self, component: Component,
+    ) -> Factory:
+        if component == DEFAULT_COMPONENT:
+            return Factory(
+                scope=self.scope,
+                source=_context_stub,
+                provides=self.provides,
+                is_to_bind=False,
+                dependencies=[],
+                type_=FactoryType.CONTEXT,
+                cache=False,
+            )
+        else:
+            aliased = alias(
+                source=self.provides.type_hint,
+                component=DEFAULT_COMPONENT,
+            )
+            return aliased.as_factory(scope=self.scope, component=component)
+
+    def __get__(self, instance, owner):
+        scope = self.scope or instance.scope
+        return ContextVariable(
+            scope=scope,
+            provides=self.provides,
+        )
+
+
+def from_context(
+        *, provides: Any, scope: BaseScope | None = None,
+) -> ContextVariable:
+    return ContextVariable(
+        provides=DependencyKey(
+            type_hint=provides,
+            component=DEFAULT_COMPONENT,
+        ),
+        scope=scope,
+    )
diff --git a/src/dishka/dependency_source/factory.py b/src/dishka/dependency_source/factory.py
index 1df9fa2..2a9ae0f 100644
--- a/src/dishka/dependency_source/factory.py
+++ b/src/dishka/dependency_source/factory.py
@@ -46,6 +46,8 @@ class FactoryType(Enum):
     FACTORY = "factory"
     ASYNC_FACTORY = "async_factory"
     VALUE = "value"
+    ALIAS = "alias"
+    CONTEXT = "context"
 
 
 def _is_bound_method(obj):
@@ -138,7 +140,7 @@ def _guess_factory_type(source):
 
 def _async_generator_result(possible_dependency: Any):
     origin = get_origin(possible_dependency)
-    if origin in (AsyncIterable, AsyncIterator, AsyncGenerator):
+    if origin is AsyncIterable:
         return get_args(possible_dependency)[0]
     elif origin is AsyncIterator:
         return get_args(possible_dependency)[0]
@@ -387,3 +389,7 @@ def provide(
         )
 
     return scoped
+
+
+def _context_stub():
+    raise NotImplementedError
diff --git a/src/dishka/entities/key.py b/src/dishka/entities/key.py
index 3117f32..6cf96b6 100644
--- a/src/dishka/entities/key.py
+++ b/src/dishka/entities/key.py
@@ -19,6 +19,9 @@ class DependencyKey(NamedTuple):
             component=component,
         )
 
+    def __str__(self):
+        return f"({self.type_hint}, component={self.component!r})"
+
 
 def hint_to_dependency_key(hint: Any) -> DependencyKey:
     if get_origin(hint) is not Annotated:
diff --git a/src/dishka/entities/scope.py b/src/dishka/entities/scope.py
index c580345..23d52c1 100644
--- a/src/dishka/entities/scope.py
+++ b/src/dishka/entities/scope.py
@@ -10,3 +10,10 @@ class Scope(BaseScope):
     REQUEST = "REQUEST"
     ACTION = "ACTION"
     STEP = "STEP"
+
+
+class InvalidScopes(BaseScope):
+    UNKNOWN_SCOPE = "<unknown scope>"
+
+    def __str__(self):
+        return self.value
diff --git a/src/dishka/error_rendering.py b/src/dishka/error_rendering.py
new file mode 100644
index 0000000..2cc22df
--- /dev/null
+++ b/src/dishka/error_rendering.py
@@ -0,0 +1,85 @@
+from collections.abc import Sequence
+
+from dishka.dependency_source import Factory, FactoryType
+from dishka.entities.component import Component
+from dishka.entities.key import DependencyKey
+from dishka.entities.scope import BaseScope
+
+
+class PathRenderer:
+
+    def _arrow_cycle(self, index: int, length: int) -> str:
+        if length == 1:
+            return "⥁"
+        elif index == 0:
+            return " → → "
+        elif index + 1 < length:
+            return "↑   ↓"
+        else:
+            return " ← ← "
+
+    def _arrow_line(self, index: int, length: int) -> str:
+        if index + 1 < length:
+            return "↓ "
+        else:
+            return " →"
+
+    def _key(self, key: DependencyKey) -> str:
+        return str(key.type_hint)
+
+    def _source(self, factory: Factory) -> str:
+        source = factory.source
+        if source == factory.provides.type_hint:
+            return ""
+        if factory.type is FactoryType.ALIAS:
+            return "alias"
+        if func := getattr(source, "__func__", None):
+            return getattr(func, "__qualname__", None) or str(func)
+        else:
+            return str(source)
+
+    def _switch(
+            self, scope: BaseScope, component: Component,
+    ) -> str:
+        return f"~~~ component={component!r}, {scope} ~~~\n"
+
+    def render(self, path: Sequence[Factory], last: DependencyKey = None):
+        if last is None:
+            _arrow = self._arrow_cycle
+        else:
+            _arrow = self._arrow_line
+
+        width = max(len(self._key(x.provides)) for x in path)
+        if last:
+            width = max(width, len(self._key(last)))
+
+        dest = None
+        length = len(path) + bool(last)
+
+        res = ""
+        for i, factory in enumerate(path):
+            arrow = _arrow(i, length)
+            new_dest = (factory.scope, factory.provides.component)
+            if new_dest != dest:
+                res += "   " + " " * len(arrow) + " " + self._switch(*new_dest)
+                dest = new_dest
+
+            res += (
+                    "   " + arrow + " " +
+                    self._key(factory.provides).ljust(width) +
+                    " " +
+                    self._source(factory) +
+                    "\n"
+            )
+        if last:
+            new_dest = (dest[0], last.component)
+            arrow = _arrow(length + 1, length)
+            if new_dest != dest:
+                res += "   " + " " * len(arrow) + " " + self._switch(*new_dest)
+                dest = new_dest
+            res += (
+                    "   " + arrow + " " +
+                    self._key(last).ljust(width) +
+                    " ???\n"
+            )
+        return res
diff --git a/src/dishka/exceptions.py b/src/dishka/exceptions.py
index d3b81a5..1a05e98 100644
--- a/src/dishka/exceptions.py
+++ b/src/dishka/exceptions.py
@@ -1,10 +1,17 @@
-from typing import Any
+from collections.abc import Sequence
+
+from dishka.entities.key import DependencyKey
+from .dependency_source import Factory
 
 try:
     from builtins import ExceptionGroup
 except ImportError:
     from exceptiongroup import ExceptionGroup
 
+from .error_rendering import PathRenderer
+
+_renderer = PathRenderer()
+
 
 class DishkaError(Exception):
     pass
@@ -14,6 +21,18 @@ class InvalidGraphError(DishkaError):
     pass
 
 
+class UnknownScopeError(InvalidGraphError):
+    pass
+
+
+class CycleDependenciesError(InvalidGraphError):
+    def __init__(self, path: Sequence[Factory]) -> None:
+        self.path = path
+
+    def __str__(self):
+        return "Cycle dependencies detected.\n" + _renderer.render(self.path)
+
+
 class ExitError(ExceptionGroup, DishkaError):
     pass
 
@@ -22,25 +41,35 @@ class UnsupportedFactoryError(DishkaError):
     pass
 
 
+class NoContextValueError(DishkaError):
+    pass
+
+
 class NoFactoryError(DishkaError):
-    def __init__(self, requested: Any):
+    def __init__(
+            self,
+            requested: DependencyKey,
+            path: Sequence[Factory] = (),
+    ) -> None:
         self.requested = requested
-        self.path = []
+        self.path = list(path)
 
-    def add_path(self, requested_by: Any):
+    def add_path(self, requested_by: Factory):
         self.path.insert(0, requested_by)
 
     def __str__(self):
         if self.path:
-            path = self.path[-1]
             return (
-                f"Cannot find factory for {self.requested} "
-                f"requested by {path}. "
-                f"It is missing or has invalid scope."
-            )
+                f"Cannot find factory for {self.requested}. "
+                f"It is missing or has invalid scope.\n"
+            ) + _renderer.render(self.path, self.requested)
         else:
             return (
                 f"Cannot find factory for {self.requested}. "
                 f"Check scopes in your providers. "
                 f"It is missing or has invalid scope."
             )
+
+
+class GraphMissingFactoryError(NoFactoryError, InvalidGraphError):
+    pass
diff --git a/src/dishka/provider.py b/src/dishka/provider.py
index 9c645bf..228d34d 100644
--- a/src/dishka/provider.py
+++ b/src/dishka/provider.py
@@ -6,11 +6,13 @@ from dishka.entities.component import DEFAULT_COMPONENT, Component
 from dishka.entities.scope import BaseScope
 from .dependency_source import (
     Alias,
+    ContextVariable,
     Decorator,
     DependencySource,
     Factory,
     alias,
     decorate,
+    from_context,
     provide,
 )
 from .exceptions import InvalidGraphError
@@ -44,6 +46,7 @@ class Provider:
         self.factories: list[Factory] = []
         self.aliases: list[Alias] = []
         self.decorators: list[Decorator] = []
+        self.context_vars: list[ContextVariable] = []
         self._init_dependency_sources()
         self.scope = self.scope or scope
         if component is not None:
@@ -66,6 +69,8 @@ class Provider:
                 self.factories.append(source)
             if isinstance(source, Decorator):
                 self.decorators.append(source)
+            if isinstance(source, ContextVariable):
+                self.context_vars.append(source)
             processed_types[source.provides] = name
 
     def provide(
@@ -124,3 +129,10 @@ class Provider:
         provider.aliases = self.aliases
         provider.decorators = self.decorators
         return provider
+
+    def from_context(
+            self, *, provides: Any, scope: BaseScope,
+    ) -> ContextVariable:
+        context_var = from_context(provides=provides, scope=scope)
+        self.context_vars.append(context_var)
+        return context_var
diff --git a/src/dishka/registry.py b/src/dishka/registry.py
index db801e7..2261da2 100644
--- a/src/dishka/registry.py
+++ b/src/dishka/registry.py
@@ -1,19 +1,32 @@
 from collections import defaultdict
+from collections.abc import Sequence
 from typing import Any, NewType, TypeVar, get_args, get_origin
 
-from dishka.entities.key import DependencyKey
-from dishka.entities.scope import BaseScope
 from ._adaptix.type_tools.basic_utils import get_type_vars, is_generic
-from .dependency_source import Factory
-from .exceptions import InvalidGraphError
+from .dependency_source import (
+    Alias,
+    ContextVariable,
+    Decorator,
+    Factory,
+    from_context,
+)
+from .entities.component import DEFAULT_COMPONENT, Component
+from .entities.key import DependencyKey
+from .entities.scope import BaseScope, InvalidScopes
+from .exceptions import (
+    CycleDependenciesError,
+    GraphMissingFactoryError,
+    NoFactoryError,
+    UnknownScopeError,
+)
 from .provider import Provider
 
 
 class Registry:
-    __slots__ = ("scope", "_factories")
+    __slots__ = ("scope", "factories")
 
     def __init__(self, scope: BaseScope):
-        self._factories: dict[DependencyKey, Factory] = {}
+        self.factories: dict[DependencyKey, Factory] = {}
         self.scope = scope
 
     def add_factory(self, factory: Factory):
@@ -21,23 +34,23 @@ class Registry:
             origin = get_origin(factory.provides.type_hint)
             if origin:
                 origin_key = DependencyKey(origin, factory.provides.component)
-                self._factories[origin_key] = factory
-        self._factories[factory.provides] = factory
+                self.factories[origin_key] = factory
+        self.factories[factory.provides] = factory
 
     def get_factory(self, dependency: DependencyKey) -> Factory | None:
         try:
-            return self._factories[dependency]
+            return self.factories[dependency]
         except KeyError:
             origin = get_origin(dependency.type_hint)
             if not origin:
                 return None
             origin_key = DependencyKey(origin, dependency.component)
-            factory = self._factories.get(origin_key)
+            factory = self.factories.get(origin_key)
             if not factory:
                 return None
 
             factory = self._specialize_generic(factory, dependency)
-            self._factories[dependency] = factory
+            self.factories[dependency] = factory
             return factory
 
     def _specialize_generic(
@@ -73,61 +86,220 @@ class Registry:
         )
 
 
-def make_registries(
-        *providers: Provider, scopes: type[BaseScope],
-) -> list[Registry]:
-    dep_scopes: dict[DependencyKey, BaseScope] = {}
-    alias_sources: dict[DependencyKey, Any] = {}
-    for provider in providers:
-        component = provider.component
-        for source in provider.factories:
-            provides = source.provides.with_component(component)
-            dep_scopes[provides] = source.scope
-        for source in provider.aliases:
-            provides = source.provides.with_component(component)
-            alias_sources[provides] = source.source.with_component(component)
+class GraphValidator:
+    def __init__(self, registries: Sequence[Registry]) -> None:
+        self.registries = registries
+        self.valid_keys = {}
+        self.path = {}
 
-    registries = {scope: Registry(scope) for scope in scopes}
-    decorator_depth: dict[DependencyKey, int] = defaultdict(int)
+    def _validate_key(
+            self, key: DependencyKey, registry_index: int,
+    ) -> None:
+        if key in self.valid_keys:
+            return
+        if key in self.path:
+            keys = list(self.path)
+            factories = list(self.path.values())[keys.index(key):]
+            raise CycleDependenciesError(factories)
+        for index in range(registry_index + 1):
+            registry = self.registries[index]
+            factory = registry.get_factory(key)
+            if factory:
+                self._validate_factory(factory, registry_index)
+                return
+        raise NoFactoryError(requested=key)
 
-    for provider in providers:
-        component = provider.component
-        for source in provider.factories:
-            scope = source.scope
-            registries[scope].add_factory(source.with_component(component))
-        for source in provider.aliases:
-            alias_source = source.source.with_component(component)
-            visited_keys = [alias_source]
-            while alias_source not in dep_scopes:
-                alias_source = alias_sources[alias_source]
-                if alias_source in visited_keys:
-                    raise InvalidGraphError(
-                        f"Cycle aliases detected {visited_keys}",
+    def _validate_factory(
+            self, factory: Factory, registry_index: int,
+    ):
+        self.path[factory.provides] = factory
+        try:
+            for dep in factory.dependencies:
+                # 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
+        finally:
+            self.path.pop(factory.provides)
+        self.valid_keys[factory.provides] = True
+
+    def validate(self):
+        for registry_index, registry in enumerate(self.registries):
+            for factory in registry.factories.values():
+                self.path = {}
+                try:
+                    self._validate_factory(factory, registry_index)
+                except NoFactoryError as e:
+                    raise GraphMissingFactoryError(
+                        e.requested, e.path,
+                    ) from None
+                except CycleDependenciesError as e:
+                    raise e from None
+
+
+class RegistryBuilder:
+    def __init__(
+            self,
+            *,
+            scopes: type[BaseScope],
+            providers: Sequence[Provider],
+            container_type: type,
+            skip_validation: bool,
+    ) -> None:
+        self.scopes = scopes
+        self.providers = providers
+        self.registries: dict[BaseScope, Registry] = {}
+        self.dependency_scopes: dict[DependencyKey, BaseScope] = {}
+        self.components: set[Component] = {DEFAULT_COMPONENT}
+        self.alias_sources: dict[DependencyKey, Any] = {}
+        self.aliases: dict[DependencyKey, Alias] = {}
+        self.container_type = container_type
+        self.decorator_depth: dict[DependencyKey, int] = defaultdict(int)
+        self.skip_validation = skip_validation
+
+    def _collect_components(self) -> None:
+        for provider in self.providers:
+            self.components.add(provider.component)
+
+    def _collect_provided_scopes(self) -> None:
+        for provider in self.providers:
+            for factory in provider.factories:
+                if not isinstance(factory.scope, self.scopes):
+                    raise UnknownScopeError(
+                        f"Scope {factory.scope} is unknown, "
+                        f"expected one of {self.scopes}",
                     )
-                visited_keys.append(alias_source)
-            scope = dep_scopes[alias_source]
-            source = source.as_factory(scope, component)
-            dep_scopes[source.provides] = scope
-            registries[scope].add_factory(source)
-        for source in provider.decorators:
-            provides = source.provides.with_component(component)
-            scope = dep_scopes[provides]
-            registry = registries[scope]
-            undecorated_type = NewType(
-                f"{provides.type_hint.__name__}@{decorator_depth[provides]}",
-                source.provides,
-            )
-            decorator_depth[provides] += 1
-            old_factory = registry.get_factory(provides)
-            old_factory.provides = DependencyKey(
-                undecorated_type, old_factory.provides.component,
+                provides = factory.provides.with_component(provider.component)
+                self.dependency_scopes[provides] = factory.scope
+            for context_var in provider.context_vars:
+                if not isinstance(context_var.scope, self.scopes):
+                    raise UnknownScopeError(
+                        f"Scope {context_var.scope} is unknown, "
+                        f"expected one of {self.scopes}",
+                    )
+                for component in self.components:
+                    provides = context_var.provides.with_component(component)
+                    self.dependency_scopes[provides] = context_var.scope
+
+    def _collect_aliases(self) -> None:
+        for provider in self.providers:
+            component = provider.component
+            for alias in provider.aliases:
+                provides = alias.provides.with_component(component)
+                alias_source = alias.source.with_component(component)
+                self.alias_sources[provides] = alias_source
+                self.aliases[provides] = alias
+
+    def _init_registries(self) -> None:
+        for scope in self.scopes:
+            registry = Registry(scope)
+            context_var = from_context(
+                provides=self.container_type, scope=scope,
             )
-            registry.add_factory(old_factory)
-            source = source.as_factory(
-                scope=scope,
-                new_dependency=DependencyKey(undecorated_type, None),
-                cache=old_factory.cache,
-                component=component,
+            for component in self.components:
+                registry.add_factory(context_var.as_factory(component))
+            self.registries[scope] = registry
+
+    def _process_factory(
+            self, provider: Provider, factory: Factory,
+    ) -> None:
+        registry = self.registries[factory.scope]
+        registry.add_factory(factory.with_component(provider.component))
+
+    def _process_alias(
+            self, provider: Provider, alias: Alias,
+    ) -> None:
+        component = provider.component
+        alias_source = alias.source.with_component(component)
+        visited_keys = []
+        while alias_source not in self.dependency_scopes:
+            if alias_source not in self.alias_sources:
+                e = NoFactoryError(alias_source)
+                for key in visited_keys[::-1]:
+                    e.add_path(self.aliases[key].as_factory(
+                        InvalidScopes.UNKNOWN_SCOPE, key.component,
+                    ))
+                e.add_path(alias.as_factory(
+                    InvalidScopes.UNKNOWN_SCOPE, component,
+                ))
+                raise e
+            visited_keys.append(alias_source)
+            alias_source = self.alias_sources[alias_source]
+            if alias_source in visited_keys:
+                raise CycleDependenciesError([
+                    self.aliases[s].as_factory(
+                        InvalidScopes.UNKNOWN_SCOPE, component,
+                    )
+                    for s in visited_keys
+                ])
+
+        scope = self.dependency_scopes[alias_source]
+        registry = self.registries[scope]
+
+        factory = alias.as_factory(scope, component)
+        self.dependency_scopes[factory.provides] = scope
+        registry.add_factory(factory)
+
+    def _process_decorator(
+            self, provider: Provider, decorator: Decorator,
+    ) -> None:
+        provides = decorator.provides.with_component(provider.component)
+        if provides not in self.dependency_scopes:
+            raise GraphMissingFactoryError(
+                requested=provides,
+                path=[decorator.as_factory(
+                    scope=InvalidScopes.UNKNOWN_SCOPE,
+                    new_dependency=provides,
+                    cache=False,
+                    component=provider.component,
+                )],
             )
-            registries[scope].add_factory(source)
-    return list(registries.values())
+        scope = self.dependency_scopes[provides]
+        registry = self.registries[scope]
+        undecorated_type = NewType(
+            f"{provides.type_hint.__name__}@{self.decorator_depth[provides]}",
+            decorator.provides.type_hint,
+        )
+        self.decorator_depth[provides] += 1
+        old_factory = registry.get_factory(provides)
+        old_factory.provides = DependencyKey(
+            undecorated_type, old_factory.provides.component,
+        )
+        new_factory = decorator.as_factory(
+            scope=scope,
+            new_dependency=DependencyKey(undecorated_type, None),
+            cache=old_factory.cache,
+            component=provider.component,
+        )
+        registry.add_factory(old_factory)
+        registry.add_factory(new_factory)
+
+    def _process_context_var(
+            self, provider: Provider, context_var: ContextVariable,
+    ) -> None:
+        registry = self.registries[context_var.scope]
+        for component in self.components:
+            registry.add_factory(context_var.as_factory(component))
+
+    def build(self):
+        self._collect_components()
+        self._collect_provided_scopes()
+        self._collect_aliases()
+        self._init_registries()
+
+        for provider in self.providers:
+            for factory in provider.factories:
+                self._process_factory(provider, factory)
+            for alias in provider.aliases:
+                self._process_alias(provider, alias)
+            for context_var in provider.context_vars:
+                self._process_context_var(provider, context_var)
+            for decorator in provider.decorators:
+                self._process_decorator(provider, decorator)
+
+        registries = list(self.registries.values())
+        if not self.skip_validation:
+            GraphValidator(registries).validate()
+        return registries


Test patch:
diff --git a/tests/unit/container/test_alias.py b/tests/unit/container/test_alias.py
index 8287696..66355db 100644
--- a/tests/unit/container/test_alias.py
+++ b/tests/unit/container/test_alias.py
@@ -1,7 +1,15 @@
 import pytest
 
-from dishka import Provider, Scope, alias, make_container, provide
-from dishka.exceptions import InvalidGraphError
+from dishka import (
+    DEFAULT_COMPONENT,
+    DependencyKey,
+    Provider,
+    Scope,
+    alias,
+    make_container,
+    provide,
+)
+from dishka.exceptions import CycleDependenciesError, NoFactoryError
 
 
 class AliasProvider(Provider):
@@ -30,5 +38,15 @@ class CycleProvider(Provider):
 
 
 def test_cycle():
-    with pytest.raises(InvalidGraphError):
+    with pytest.raises(CycleDependenciesError):
         make_container(CycleProvider())
+
+
+def test_missing_factory():
+    p1 = Provider()
+    p1.alias(source=bool, provides=float)
+    p2 = Provider()
+    p2.alias(source=int, provides=bool)
+    with pytest.raises(NoFactoryError) as e:
+        make_container(p1, p2)
+    assert e.value.requested == DependencyKey(int, component=DEFAULT_COMPONENT)
diff --git a/tests/unit/container/test_components.py b/tests/unit/container/test_components.py
index a0528d0..492c7a0 100644
--- a/tests/unit/container/test_components.py
+++ b/tests/unit/container/test_components.py
@@ -72,13 +72,25 @@ async def test_from_component_async():
         await container.get(float)
 
 
+class SingleProvider(Provider):
+    scope = Scope.APP
+
+    def __init__(self, value: int, component: Component | None = None):
+        super().__init__(component=component)
+        self.value = value
+
+    @provide
+    def bar(self) -> int:
+        return self.value
+
+
 def test_change_component():
-    container = make_container(MainProvider(20).to_component("Y"))
+    container = make_container(SingleProvider(20).to_component("Y"))
     assert container.get(int, component="Y") == 20
 
 
 def test_set_component():
-    container = make_container(MainProvider(20, component="Y"))
+    container = make_container(SingleProvider(20, component="Y"))
     assert container.get(int, component="Y") == 20
 
 
diff --git a/tests/unit/container/test_context_vars.py b/tests/unit/container/test_context_vars.py
new file mode 100644
index 0000000..1cac587
--- /dev/null
+++ b/tests/unit/container/test_context_vars.py
@@ -0,0 +1,59 @@
+import pytest
+
+from dishka import (
+    Provider,
+    Scope,
+    make_async_container,
+    make_container,
+    provide,
+)
+from dishka.dependency_source import from_context
+from dishka.exceptions import NoContextValueError
+
+
+def test_simple():
+    provider = Provider()
+    provider.from_context(provides=int, scope=Scope.APP)
+    container = make_container(provider, context={int: 1})
+    assert container.get(int) == 1
+
+
+@pytest.mark.asyncio
+async def test_simple_async():
+    provider = Provider()
+    provider.from_context(provides=int, scope=Scope.APP)
+    container = make_async_container(provider, context={int: 1})
+    assert await container.get(int) == 1
+
+
+def test_not_found():
+    provider = Provider()
+    provider.from_context(provides=int, scope=Scope.APP)
+    container = make_container(provider)
+    with pytest.raises(NoContextValueError):
+        assert container.get(int) == 1
+
+
+@pytest.mark.asyncio
+async def test_not_found_async():
+    provider = Provider()
+    provider.from_context(provides=int, scope=Scope.APP)
+    container = make_async_container(provider)
+    with pytest.raises(NoContextValueError):
+        assert await container.get(int) == 1
+
+
+@pytest.mark.asyncio
+async def test_2components():
+    class MyProvider(Provider):
+        scope = Scope.APP
+        component = "XXX"
+
+        a = from_context(provides=int)
+
+        @provide
+        def foo(self, a: int) -> float:
+            return a
+
+    container = make_async_container(MyProvider(), context={int: 1})
+    assert await container.get(float, component="XXX") == 1
diff --git a/tests/unit/container/test_decorator.py b/tests/unit/container/test_decorator.py
index 8888840..7a50360 100644
--- a/tests/unit/container/test_decorator.py
+++ b/tests/unit/container/test_decorator.py
@@ -1,7 +1,16 @@
 import pytest
 
-from dishka import Provider, Scope, alias, decorate, make_container, provide
-from dishka.exceptions import InvalidGraphError
+from dishka import (
+    DEFAULT_COMPONENT,
+    DependencyKey,
+    Provider,
+    Scope,
+    alias,
+    decorate,
+    make_container,
+    provide,
+)
+from dishka.exceptions import InvalidGraphError, NoFactoryError
 
 
 class A:
@@ -113,3 +122,14 @@ def test_double_ok():
     assert isinstance(a, ADecorator)
     assert isinstance(a.a, ADecorator)
     assert isinstance(a.a.a, A)
+
+
+def test_missing_factory():
+    class MyProvider(Provider):
+        @decorate
+        def foo(self, a: int) -> int:
+            return a + 1
+
+    with pytest.raises(NoFactoryError) as e:
+        make_container(MyProvider())
+    assert e.value.requested == DependencyKey(int, component=DEFAULT_COMPONENT)
diff --git a/tests/unit/container/test_dynamic.py b/tests/unit/container/test_dynamic.py
index 9efbe51..4ed6b94 100644
--- a/tests/unit/container/test_dynamic.py
+++ b/tests/unit/container/test_dynamic.py
@@ -1,6 +1,7 @@
 from typing import NewType
 
 from dishka import Container, Provider, Scope, make_container, provide
+from dishka.dependency_source import from_context
 
 Request = NewType("Request", int)
 
@@ -18,6 +19,7 @@ class A1(A):
 
 
 class MyProvider(Provider):
+    request = from_context(provides=Request, scope=Scope.REQUEST)
     a0 = provide(A0, scope=Scope.APP)
     a1 = provide(A1, scope=Scope.APP)
 
diff --git a/tests/unit/container/test_exceptions.py b/tests/unit/container/test_exceptions.py
index cea0358..973634a 100644
--- a/tests/unit/container/test_exceptions.py
+++ b/tests/unit/container/test_exceptions.py
@@ -9,13 +9,16 @@ from dishka import (
     DependencyKey,
     Provider,
     Scope,
+    from_context,
     make_async_container,
     make_container,
     provide,
 )
 from dishka.exceptions import (
     ExitError,
+    NoContextValueError,
     NoFactoryError,
+    UnknownScopeError,
     UnsupportedFactoryError,
 )
 
@@ -85,11 +88,7 @@ async def test_async(dep_type):
     finalizer.assert_called_once()
 
 
-class InvalidScopeProvider(Provider):
-    @provide(scope=Scope.REQUEST)
-    def y(self) -> object:
-        return False
-
+class MissingFactoryProvider(Provider):
     @provide(scope=Scope.APP)
     def x(self, value: object) -> int:
         return 1
@@ -103,29 +102,50 @@ class InvalidScopeProvider(Provider):
         return value
 
 
+def test_no_factory_init_sync():
+    with pytest.raises(NoFactoryError) as e:
+        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:
+        make_async_container(MissingFactoryProvider())
+    assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
+
+
 def test_no_factory_sync():
-    container = make_container(InvalidScopeProvider())
+    container = make_container(Provider())
+    with pytest.raises(NoFactoryError) as e:
+        container.get(object)
+    assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
+
+
+def test_no_factory_path_sync():
+    container = make_container(MissingFactoryProvider(), skip_validation=True)
     with pytest.raises(NoFactoryError) as e:
         container.get(complex)
     assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
-    assert e.value.path == [
-        DependencyKey(complex, DEFAULT_COMPONENT),
-        DependencyKey(float, DEFAULT_COMPONENT),
-        DependencyKey(int, DEFAULT_COMPONENT),
-    ]
+
 
 
 @pytest.mark.asyncio
 async def test_no_factory_async():
-    container = make_async_container(InvalidScopeProvider())
+    container = make_async_container(Provider())
+    with pytest.raises(NoFactoryError) as e:
+        await container.get(object)
+    assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
+
+
+
+@pytest.mark.asyncio
+async def test_no_factory_path_async():
+    container = make_async_container(
+        MissingFactoryProvider(), skip_validation=True,
+    )
     with pytest.raises(NoFactoryError) as e:
         await container.get(complex)
     assert e.value.requested == DependencyKey(object, DEFAULT_COMPONENT)
-    assert e.value.path == [
-        DependencyKey(complex, DEFAULT_COMPONENT),
-        DependencyKey(float, DEFAULT_COMPONENT),
-        DependencyKey(int, DEFAULT_COMPONENT),
-    ]
 
 
 class AsyncProvider(Provider):
@@ -138,3 +158,39 @@ def test_async_factory_in_sync():
     container = make_container(AsyncProvider())
     with pytest.raises(UnsupportedFactoryError):
         container.get(int)
+
+
+def test_invalid_scope_factory():
+    class InvalidScopeProvider(Provider):
+        @provide(scope="invalid")
+        def foo(self) -> int:
+            return 1
+
+    with pytest.raises(UnknownScopeError):
+        make_container(InvalidScopeProvider())
+
+
+def test_invalid_scope_context_var():
+    class InvalidScopeProvider(Provider):
+        a = from_context(provides=int, scope="invalid")
+
+    with pytest.raises(UnknownScopeError):
+        make_container(InvalidScopeProvider())
+
+
+def test_missing_context_var_sync():
+    class MyProvider(Provider):
+        a = from_context(provides=int, scope=Scope.APP)
+
+    container = make_container(MyProvider())
+    with pytest.raises(NoContextValueError):
+        container.get(int)
+
+@pytest.mark.asyncio
+async def test_missing_context_var_async():
+    class MyProvider(Provider):
+        a = from_context(provides=int, scope=Scope.APP)
+
+    container = make_async_container(MyProvider())
+    with pytest.raises(NoContextValueError):
+        await container.get(int)
diff --git a/tests/unit/sample_providers.py b/tests/unit/sample_providers.py
index 5f05e81..078a29f 100644
--- a/tests/unit/sample_providers.py
+++ b/tests/unit/sample_providers.py
@@ -1,4 +1,11 @@
-from collections.abc import AsyncGenerator, AsyncIterable, Generator, Iterable
+from collections.abc import (
+    AsyncGenerator,
+    AsyncIterable,
+    AsyncIterator,
+    Generator,
+    Iterable,
+    Iterator,
+)
 from typing import Any
 
 from dishka import DependencyKey, Scope
@@ -21,6 +28,12 @@ def sync_iter_a(self, dep: int) -> Iterable[ClassA]:
     a.closed = True
 
 
+def sync_iterator_a(self, dep: int) -> Iterator[ClassA]:
+    a = ClassA(dep)
+    yield a
+    a.closed = True
+
+
 def sync_gen_a(self, dep: int) -> Generator[None, ClassA, None]:
     a = ClassA(dep)
     yield a
@@ -37,6 +50,12 @@ async def async_iter_a(self, dep: int) -> AsyncIterable[ClassA]:
     a.closed = True
 
 
+async def async_iterator_a(self, dep: int) -> AsyncIterator[ClassA]:
+    a = ClassA(dep)
+    yield a
+    a.closed = True
+
+
 async def async_gen_a(self, dep: int) -> AsyncGenerator[ClassA, None]:
     a = ClassA(dep)
     yield a
diff --git a/tests/unit/test_provider.py b/tests/unit/test_provider.py
index cd76695..39fc185 100644
--- a/tests/unit/test_provider.py
+++ b/tests/unit/test_provider.py
@@ -13,9 +13,11 @@ from .sample_providers import (
     async_func_a,
     async_gen_a,
     async_iter_a,
+    async_iterator_a,
     sync_func_a,
     sync_gen_a,
     sync_iter_a,
+    sync_iterator_a,
 )
 
 
@@ -37,9 +39,11 @@ def test_provider_init():
     [
         (sync_func_a, FactoryType.FACTORY, True),
         (sync_iter_a, FactoryType.GENERATOR, True),
+        (sync_iterator_a, FactoryType.GENERATOR, True),
         (sync_gen_a, FactoryType.GENERATOR, True),
         (async_func_a, FactoryType.ASYNC_FACTORY, True),
         (async_iter_a, FactoryType.ASYNC_GENERATOR, True),
+        (async_iterator_a, FactoryType.ASYNC_GENERATOR, True),
         (async_gen_a, FactoryType.ASYNC_GENERATOR, True),
     ],
 )
@@ -53,6 +57,22 @@ def test_parse_factory(source, provider_type, is_to_bound):
     assert factory.type == provider_type
 
 
+def test_parse_factory_invalid_hint():
+    def foo() -> int:
+        yield 1
+
+    with pytest.raises(TypeError):
+        provide(foo)
+
+
+def test_parse_factory_invalid_hint_async():
+    async def foo() -> int:
+        yield 1
+
+    with pytest.raises(TypeError):
+        provide(foo)
+
+
 @pytest.mark.parametrize(
     ("source", "provider_type", "is_to_bound"),
     [
diff --git a/tests/unit/test_render.py b/tests/unit/test_render.py
new file mode 100644
index 0000000..8b04d94
--- /dev/null
+++ b/tests/unit/test_render.py
@@ -0,0 +1,56 @@
+import textwrap
+
+import pytest
+
+from dishka import provide
+from dishka.error_rendering import PathRenderer
+
+
+class A:
+    def foo(self) -> int:
+        pass
+
+
+@pytest.fixture
+def renderer():
+    return PathRenderer()
+
+
+def test_cycle(renderer):
+    foo = A().foo
+    factory = provide(foo)
+
+    res = renderer.render([factory, factory, factory])
+    res = textwrap.dedent(res)
+    assert res == textwrap.dedent("""\
+              ~~~ component=None, None ~~~
+         → →  <class 'int'> A.foo
+        ↑   ↓ <class 'int'> A.foo
+         ← ←  <class 'int'> A.foo
+    """)
+
+
+def test_cycle_1(renderer):
+    foo = A().foo
+    factory = provide(foo)
+
+    res = renderer.render([factory])
+    res = textwrap.dedent(res)
+    assert res == textwrap.dedent("""\
+      ~~~ component=None, None ~~~
+    ⥁ <class 'int'> A.foo
+    """)
+
+
+def test_linear(renderer):
+    foo = A().foo
+    factory = provide(foo)
+
+    res = renderer.render([factory, factory], last=factory.provides)
+    res = textwrap.dedent(res)
+    assert res == textwrap.dedent("""\
+       ~~~ component=None, None ~~~
+    ↓  <class 'int'> A.foo
+    ↓  <class 'int'> A.foo
+     → <class 'int'> ???
+    """)