Repository: reagento/dishka
Pull request #446 (2025-04-24 10:00:56)

More smooth graph in errors
More pretty lines in error graph


Patch:
diff --git a/docs/advanced/context.rst b/docs/advanced/context.rst
index 5981948..ac40ed3 100644
--- a/docs/advanced/context.rst
+++ b/docs/advanced/context.rst
@@ -8,10 +8,6 @@ The difference from normal factories is that they are not created inside some ``
 Working with context data consists of three parts:
 
 1. Declaration that object is received from context using :ref:`from-context`. You need to provide the type and scope.
-    * For the context passed to ``make_container`` and ``make_async_container`` functions it is done automatically in default component.
-    * Context is shared across all providers. You do not need to specify it in each provider.
-    * For the frameworks integrations you can use predefined providers instead of defining context data manually
-    * To access context from additional components you need to use ``from_context`` is each of them in addition to default component.
 2. Usage of that object in providers.
 3. Passing actual values on scope entrance. It can be container creation for top level scope or container calls for nested ones. Use it in form ``context={Type: value,...}``.
 
@@ -24,15 +20,16 @@ Working with context data consists of three parts:
     class MyProvider(Provider):
         scope = Scope.REQUEST
 
-        # declare context data for nested scope
+        # declare source
         request = from_context(provides=Request, scope=Scope.REQUEST)
+        event_broker = from_context(provides=Broker, scope=Scope.APP)
 
         # use objects as usual
         @provide
         def a(self, request: Request, broker: Broker) -> A:
             return A(data=request.contents)
 
-    # passed APP-scoped context variable is automatically available as a dependency
+    # provide APP-scoped context variable
     container = make_container(MyProvider(), context={Broker: broker})
 
     while True:
@@ -43,4 +40,4 @@ Working with context data consists of three parts:
 
 .. note::
 
-    If you are using *multiple components*, you need to specify ``from_context`` in them separately though the context is share.
+    If you are using *multiple components*, you need to specify ``from_context`` in them separately though the context is shared. Context data is always stored in default component, so, other components may not use it and have factories instead.
diff --git a/src/dishka/async_container.py b/src/dishka/async_container.py
index f95ff1b..3627f1a 100644
--- a/src/dishka/async_container.py
+++ b/src/dishka/async_container.py
@@ -22,7 +22,7 @@ from .exceptions import (
     NoFactoryError,
     NoNonSkippedScopesError,
 )
-from .provider import BaseProvider, make_root_context_provider
+from .provider import BaseProvider
 from .registry import Registry
 from .registry_builder import RegistryBuilder
 
@@ -243,11 +243,10 @@ def make_async_container(
         start_scope: BaseScope | None = None,
         validation_settings: ValidationSettings = DEFAULT_VALIDATION,
 ) -> AsyncContainer:
-    context_provider = make_root_context_provider(context, scopes)
     registries = RegistryBuilder(
         scopes=scopes,
         container_key=CONTAINER_KEY,
-        providers=(*providers, context_provider),
+        providers=providers,
         skip_validation=skip_validation,
         validation_settings=validation_settings,
     ).build()
diff --git a/src/dishka/container.py b/src/dishka/container.py
index b62e3d4..e87bea3 100644
--- a/src/dishka/container.py
+++ b/src/dishka/container.py
@@ -22,7 +22,7 @@ from .exceptions import (
     NoFactoryError,
     NoNonSkippedScopesError,
 )
-from .provider import BaseProvider, make_root_context_provider
+from .provider import BaseProvider
 from .registry import Registry
 from .registry_builder import RegistryBuilder
 
@@ -239,11 +239,10 @@ def make_container(
         start_scope: BaseScope | None = None,
         validation_settings: ValidationSettings = DEFAULT_VALIDATION,
 ) -> Container:
-    context_provider = make_root_context_provider(context, scopes)
     registries = RegistryBuilder(
         scopes=scopes,
         container_key=CONTAINER_KEY,
-        providers=(*providers, context_provider),
+        providers=providers,
         skip_validation=skip_validation,
         validation_settings=validation_settings,
     ).build()
diff --git a/src/dishka/exceptions.py b/src/dishka/exceptions.py
index d1af7e7..fddac30 100644
--- a/src/dishka/exceptions.py
+++ b/src/dishka/exceptions.py
@@ -18,7 +18,8 @@ except ImportError:
         ExceptionGroup,
     )
 
-_renderer = PathRenderer()
+_cycle_renderer = PathRenderer(cycle=True)
+_linear_renderer = PathRenderer(cycle=False)
 
 
 class ExitError(
@@ -77,7 +78,7 @@ class NoFactoryError(DishkaError):
             return (
                 f"Cannot find factory for {requested_repr}. "
                 f"It is missing or has invalid scope.\n"
-            ) + _renderer.render(self.path, self.requested) + suggestion
+            ) + _linear_renderer.render(self.path, self.requested) + suggestion
         else:
             requested_repr = (
                 f"({requested_name}, "
@@ -159,7 +160,7 @@ class CycleDependenciesError(InvalidGraphError):
             hint = " Did you mean @decorate instead of @provide?"
         else:
             hint = ""
-        details = _renderer.render(self.path)
+        details = _cycle_renderer.render(self.path)
         return f"Cycle dependencies detected.{hint}\n{details}"
 
 
diff --git a/src/dishka/provider/__init__.py b/src/dishka/provider/__init__.py
index dbb16b0..2f95d96 100644
--- a/src/dishka/provider/__init__.py
+++ b/src/dishka/provider/__init__.py
@@ -5,7 +5,6 @@ __all__ = [
     "alias",
     "decorate",
     "from_context",
-    "make_root_context_provider",
     "provide",
     "provide_all",
 ]
@@ -16,4 +15,3 @@ from .make_context_var import from_context
 from .make_decorator import decorate
 from .make_factory import provide, provide_all
 from .provider import Provider
-from .root_context import make_root_context_provider
diff --git a/src/dishka/provider/root_context.py b/src/dishka/provider/root_context.py
deleted file mode 100644
index 0c010ad..0000000
--- a/src/dishka/provider/root_context.py
+++ /dev/null
@@ -1,18 +0,0 @@
-from typing import Any
-
-from dishka.entities.scope import BaseScope
-from .base_provider import BaseProvider
-from .provider import Provider
-
-
-def make_root_context_provider(
-        context: dict[Any, Any] | None,
-        scopes: type[BaseScope],
-) -> BaseProvider:
-    p = Provider()
-    if not context:
-        return p
-    root_scope = next(iter(scopes))
-    for key in context:
-        p.from_context(provides=key, scope=root_scope)
-    return p
diff --git a/src/dishka/registry_builder.py b/src/dishka/registry_builder.py
index 7f74c81..e0fe0f1 100644
--- a/src/dishka/registry_builder.py
+++ b/src/dishka/registry_builder.py
@@ -387,26 +387,27 @@ class RegistryBuilder:
                     "Define it explicitly in Provider or from_context"
                 ),
             )
-        factory = context_var.as_factory(provider.component)
-        if (
-            self.validation_settings.nothing_overridden
-            and not self.skip_validation
-            and factory.override
-            and factory.provides not in self.processed_factories
-        ):
-            raise NothingOverriddenError(factory)
-
-        if (
-            self.validation_settings.implicit_override
-            and not self.skip_validation
-            and not factory.override
-            and factory.provides in self.processed_factories
-        ):
-            raise ImplicitOverrideDetectedError(
-                factory,
-                self.processed_factories[factory.provides],
-            )
-        self.processed_factories[factory.provides] = factory
+        for component in self.components:
+            factory = context_var.as_factory(component)
+            if (
+                self.validation_settings.nothing_overridden
+                and not self.skip_validation
+                and factory.override
+                and factory.provides not in self.processed_factories
+            ):
+                raise NothingOverriddenError(factory)
+
+            if (
+                self.validation_settings.implicit_override
+                and not self.skip_validation
+                and not factory.override
+                and factory.provides in self.processed_factories
+            ):
+                raise ImplicitOverrideDetectedError(
+                    factory,
+                    self.processed_factories[factory.provides],
+                )
+            self.processed_factories[factory.provides] = factory
 
     def build(self) -> tuple[Registry, ...]:
         self._collect_components()
diff --git a/src/dishka/text_rendering/path.py b/src/dishka/text_rendering/path.py
index 4ed5828..ff9b03d 100644
--- a/src/dishka/text_rendering/path.py
+++ b/src/dishka/text_rendering/path.py
@@ -1,4 +1,5 @@
 from collections.abc import Sequence
+from typing import NamedTuple
 
 from dishka.entities.component import Component
 from dishka.entities.factory_type import FactoryData, FactoryType
@@ -8,21 +9,39 @@ from dishka.text_rendering import get_name
 
 
 class PathRenderer:
+    def __init__(self, *, cycle: bool):
+        self.cycle = cycle
+
     def _arrow_cycle(self, index: int, length: int) -> str:
         if length == 1:
-            return "⥁"
+            return "⥁ "
         elif index == 0:
-            return " → → "
+            return "╭─▷─╮ "
         elif index + 1 < length:
-            return "↑   ↓"
+            return "│   ▼ "
         else:
-            return " ← ← "
+            return "╰─◁─╯ "
 
     def _arrow_line(self, index: int, length: int) -> str:
         if index + 1 < length:
-            return "↓ "
+            return "▼   "
         else:
-            return " →"
+            return "╰─▷ "
+
+    def _arrow(self, index: int, length: int) -> str:
+        if self.cycle:
+            return self._arrow_cycle(index, length)
+        return self._arrow_line(index, length)
+
+    def _switch_arrow(self, index: int, length: int) -> str:
+        if self.cycle:
+            if index> 0:
+                return "│   │ "
+            return "      "
+        return "│   "
+
+    def _switch_filler(self) -> str:
+        return " "
 
     def _key(self, key: DependencyKey) -> str:
         return get_name(key.type_hint, include_module=True)
@@ -39,49 +58,71 @@ class PathRenderer:
     def _switch(
             self, scope: BaseScope | None, component: Component | None,
     ) -> str:
-        return f"~~~ component={component!r}, {scope} ~~~\n"
+        return f"◈ {scope}, component={component!r} ◈"
 
     def render(
             self,
             path: Sequence[FactoryData],
             last: DependencyKey | None = None,
     ) -> str:
-        if last is None:
-            _arrow = self._arrow_cycle
-        else:
-            _arrow = self._arrow_line
-
-        width = max(len(self._key(x.provides)) for x in path)
+        row_count = len(path) + bool(last)
+        rows = [
+            Row(
+                row_num,
+                self._arrow(row_num, row_count),
+                [self._key(factory.provides), self._source(factory)],
+                (factory.scope, factory.provides.component),
+            )
+            for row_num, factory in enumerate(path)
+        ]
         if last:
-            width = max(width, len(self._key(last)))
-        width += 2  # add spacing between columns
-
-        dest: tuple[BaseScope | None, Component | None] = (None, "")
-        length = len(path) + bool(last)
-
+            rows.append(
+                Row(
+                    row_count - 1,
+                    self._arrow(row_count-1, row_count),
+                    [self._key(last), "???"],
+                    (rows[-1].dest[0], last.component),
+                ),
+            )
+        prev_dest: tuple[BaseScope | None, Component | None] = (None, "")
+        space_left = "   "
+        space_between = "   "
         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) +
+        columns_count = len(rows[0].columns)
+        columns_width = [
+            max(len(row.columns[col_num]) for row in rows)
+            for col_num in range(columns_count)
+        ]
+        switch_len = (
+            sum(columns_width) +
+            len(space_between) * (columns_count - 1)
+        )
+        for row in rows:
+            if row.dest != prev_dest:
+                res += (
+                    space_left +
+                    self._switch_arrow(row.num, row_count) +
+                    self._switch(*row.dest).center(
+                        switch_len, self._switch_filler(),
+                    ) +
                     "\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)
+                )
+                prev_dest = row.dest
             res += (
-                    "   " + arrow + " " +
-                    self._key(last).ljust(width) +
-                    " ???\n"
+                space_left +
+                row.border_left +
+                space_between.join(
+                    c.ljust(cw)
+                    for c, cw in zip(row.columns, columns_width, strict=False)
+                ) +
+                "\n"
             )
         return res
+
+
+class Row(NamedTuple):
+    num: int
+    border_left: str
+    columns: Sequence[str]
+    dest: tuple[BaseScope | None, Component | None]


Test patch:
diff --git a/tests/unit/container/test_context_vars.py b/tests/unit/container/test_context_vars.py
index bb51653..f2002ee 100644
--- a/tests/unit/container/test_context_vars.py
+++ b/tests/unit/container/test_context_vars.py
@@ -75,7 +75,7 @@ async def test_2components():
 
 
 @pytest.mark.asyncio
-async def test_2components_factory_auto_context():
+async def test_2components_factory():
     class MyProvider(Provider):
         scope = Scope.APP
         component = "XXX"
@@ -109,28 +109,3 @@ def test_decorate():
     p2.provide(lambda: 2, provides=int)
     with pytest.raises(InvalidGraphError):
         make_container(MyProvider(), p2)
-
-
-def test_automatic_context():
-    class MyProvider(Provider):
-        scope = Scope.APP
-
-        @provide
-        def ii(self, i: int) -> str:
-            return str(i)
-
-    c = make_container(MyProvider(), context={int: 1})
-    assert c.get(str) == "1"
-
-
-@pytest.mark.asyncio
-async def test_automatic_context_async():
-    class MyProvider(Provider):
-        scope = Scope.APP
-
-        @provide
-        def ii(self, i: int) -> str:
-            return str(i)
-
-    c = make_async_container(MyProvider(), context={int: 1})
-    assert await c.get(str) == "1"
diff --git a/tests/unit/text_rendering/test_path.py b/tests/unit/text_rendering/test_path.py
index 9736a1b..940be83 100644
--- a/tests/unit/text_rendering/test_path.py
+++ b/tests/unit/text_rendering/test_path.py
@@ -1,7 +1,9 @@
 import textwrap
+from typing import Annotated
 
 import pytest
 
+from dishka import FromComponent, Scope
 from dishka.provider.make_factory import make_factory
 from dishka.text_rendering.path import PathRenderer
 
@@ -12,11 +14,16 @@ class A:
 
 
 @pytest.fixture
-def renderer():
-    return PathRenderer()
+def cycle_renderer():
+    return PathRenderer(cycle=True)
 
 
-def test_cycle(renderer):
+@pytest.fixture
+def linear_renderer():
+    return PathRenderer(cycle=False)
+
+
+def test_cycle(cycle_renderer):
     foo = A().foo
     factory = make_factory(
         provides=None,
@@ -27,36 +34,49 @@ def test_cycle(renderer):
         override=False,
     )
 
-    res = renderer.render([factory, factory, factory])
+    res = cycle_renderer.render([factory, factory, factory])
     res = textwrap.dedent(res)
     assert res == textwrap.dedent("""\
-              ~~~ component=None, None ~~~
-         → →  int   A.foo
-        ↑   ↓ int   A.foo
-         ← ←  int   A.foo
+            ◈ None, component=None ◈
+      ╭─▷─╮ int   A.foo
+      │   ▼ int   A.foo
+      ╰─◁─╯ int   A.foo
     """)
 
 
-def test_cycle_1(renderer):
+def test_cycle_2scopes(cycle_renderer):
     foo = A().foo
+    app_factory = make_factory(
+        provides=Annotated[int, FromComponent("")],
+        source=foo,
+        cache=False,
+        scope=Scope.APP,
+        is_in_class=True,
+        override=False,
+    )
     factory = make_factory(
-        provides=None,
+        provides=Annotated[int, FromComponent("")],
         source=foo,
         cache=False,
-        scope=None,
+        scope=Scope.REQUEST,
         is_in_class=True,
         override=False,
     )
 
-    res = renderer.render([factory])
+
+    res = cycle_renderer.render([app_factory, factory, factory, factory])
     res = textwrap.dedent(res)
     assert res == textwrap.dedent("""\
-      ~~~ component=None, None ~~~
-    ⥁ int   A.foo
+            ◈ Scope.APP, component='' ◈
+      ╭─▷─╮ int   A.foo
+      │   │ ◈ Scope.REQUEST, component='' ◈
+      │   ▼ int   A.foo
+      │   ▼ int   A.foo
+      ╰─◁─╯ int   A.foo
     """)
 
 
-def test_linear(renderer):
+def test_cycle_1(cycle_renderer):
     foo = A().foo
     factory = make_factory(
         provides=None,
@@ -67,11 +87,63 @@ def test_linear(renderer):
         override=False,
     )
 
-    res = renderer.render([factory, factory], last=factory.provides)
+    res = cycle_renderer.render([factory])
     res = textwrap.dedent(res)
     assert res == textwrap.dedent("""\
-       ~~~ component=None, None ~~~
-    ↓  int   A.foo
-    ↓  int   A.foo
-     → int   ???
+          ◈ None, component=None ◈
+    ⥁ int   A.foo
     """)
+
+
+def test_linear(linear_renderer):
+    foo = A().foo
+    factory = make_factory(
+        provides=Annotated[int, FromComponent("")],
+        source=foo,
+        cache=False,
+        scope=Scope.APP,
+        is_in_class=True,
+        override=False,
+    )
+
+    res = linear_renderer.render([factory, factory], last=factory.provides)
+    res = textwrap.dedent(res)
+    assert res == textwrap.dedent("""\
+    │   ◈ Scope.APP, component='' ◈
+    ▼   int   A.foo
+    ▼   int   A.foo
+    ╰─▷ int   ???  
+    """)  # noqa: W291
+
+def test_linear_2scopes(linear_renderer):
+    foo = A().foo
+    app_factory = make_factory(
+        provides=Annotated[int, FromComponent("")],
+        source=foo,
+        cache=False,
+        scope=Scope.APP,
+        is_in_class=True,
+        override=False,
+    )
+    factory = make_factory(
+        provides=Annotated[int, FromComponent("")],
+        source=foo,
+        cache=False,
+        scope=Scope.REQUEST,
+        is_in_class=True,
+        override=False,
+    )
+
+    res = linear_renderer.render(
+        [app_factory, factory, factory],
+        last=factory.provides,
+    )
+    res = textwrap.dedent(res)
+    assert res == textwrap.dedent("""\
+    │   ◈ Scope.APP, component='' ◈
+    ▼   int   A.foo
+    │   ◈ Scope.REQUEST, component='' ◈
+    ▼   int   A.foo
+    ▼   int   A.foo
+    ╰─▷ int   ???  
+    """)  # noqa: W291