Repository: reagento/dishka

Issue:
Graph visualisation
Add function to render  all dependencies into one file. HTML with Mermaid can be good for start

Discussion:

Patch:
diff --git a/.ruff.toml b/.ruff.toml
index deb6203..15bbca6 100644
--- a/.ruff.toml
+++ b/.ruff.toml
@@ -26,6 +26,7 @@ lint.ignore = [
    "UP038",
    "TCH001",
    "SIM103",
+   "ISC003",
 ]
 
 [lint.per-file-ignores]
diff --git a/docs/advanced/plotter.rst b/docs/advanced/plotter.rst
new file mode 100644
index 0000000..450bcd2
--- /dev/null
+++ b/docs/advanced/plotter.rst
@@ -0,0 +1,12 @@
+Dependency graph plotter
+==============================
+
+You can visualise your dependency graph by calling one of these functions
+
+* ``dishka.plotter.render_d2(container)`` will produce a string in d2 lang format. Follow `<https://d2lang.com>`_ for more details how to show it
+* ``dishka.plotter.render_mermaid(container)`` will produce a string containing ready to show HTML text containing graph in mermaid js format. Follow `<https://mermaid.js.org>`_ for more details on its customization.
+
+
+The example rendered with mermaid:
+
+.. image:: ./plotter.png
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
index be97a6a..e56f530 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -39,6 +39,7 @@ Main ideas:
    advanced/generics
    advanced/scopes
    advanced/testing/index
+   advanced/plotter
 
 .. toctree::
    :hidden:
diff --git a/src/dishka/async_container.py b/src/dishka/async_container.py
index 279fd53..769a4e0 100644
--- a/src/dishka/async_container.py
+++ b/src/dishka/async_container.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import warnings
 from asyncio import Lock
 from collections.abc import Callable, MutableMapping
-from contextlib import AbstractAsyncContextManager
 from types import TracebackType
 from typing import Any, cast
 
@@ -41,9 +40,7 @@ class AsyncContainer:
             *child_registries: Registry,
             parent_container: AsyncContainer | None = None,
             context: dict[Any, Any] | None = None,
-            lock_factory: Callable[
-                [], AbstractAsyncContextManager[Any],
-            ] | None = None,
+            lock_factory: Callable[[], Lock] | None = None,
             close_parent: bool = False,
     ):
         self.registry = registry
@@ -57,7 +54,7 @@ class AsyncContainer:
         self._cache = {**self._context}
         self.parent_container = parent_container
 
-        self.lock: AbstractAsyncContextManager[Any] | None
+        self.lock: Lock | None
         if lock_factory:
             self.lock = lock_factory()
         else:
@@ -77,9 +74,7 @@ class AsyncContainer:
     def __call__(
             self,
             context: dict[Any, Any] | None = None,
-            lock_factory: Callable[
-                [], AbstractAsyncContextManager[Any],
-            ] | None = None,
+            lock_factory: Callable[[], Lock] | None = None,
             scope: BaseScope | None = None,
     ) -> AsyncContextWrapper:
         """
@@ -199,9 +194,7 @@ def make_async_container(
         *providers: BaseProvider,
         scopes: type[BaseScope] = Scope,
         context: dict[Any, Any] | None = None,
-        lock_factory: Callable[
-            [], AbstractAsyncContextManager[Any],
-        ] | None = Lock,
+        lock_factory: Callable[[], Lock] | None = Lock,
         skip_validation: bool = False,
         start_scope: BaseScope | None = None,
         validation_settings: ValidationSettings = DEFAULT_VALIDATION,
diff --git a/src/dishka/container.py b/src/dishka/container.py
index dc8bef9..c15c4d8 100644
--- a/src/dishka/container.py
+++ b/src/dishka/container.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import warnings
 from collections.abc import Callable, MutableMapping
-from contextlib import AbstractContextManager
 from threading import Lock
 from types import TracebackType
 from typing import Any, cast
@@ -41,9 +40,7 @@ class Container:
             *child_registries: Registry,
             parent_container: Container | None = None,
             context: dict[Any, Any] | None = None,
-            lock_factory: Callable[
-                [], AbstractContextManager[Any],
-            ] | None = None,
+            lock_factory: Callable[[], Lock] | None = None,
             close_parent: bool = False,
     ):
         self.registry = registry
@@ -57,7 +54,7 @@ class Container:
         self._cache = {**self._context}
         self.parent_container = parent_container
 
-        self.lock: AbstractContextManager[Any] | None
+        self.lock: Lock | None
         if lock_factory:
             self.lock = lock_factory()
         else:
@@ -77,9 +74,7 @@ class Container:
     def __call__(
             self,
             context: dict[Any, Any] | None = None,
-            lock_factory: Callable[
-                [], AbstractContextManager[Any],
-            ] | None = None,
+            lock_factory: Callable[[], Lock] | None = None,
             scope: BaseScope | None = None,
     ) -> ContextWrapper:
         """
@@ -197,7 +192,7 @@ def make_container(
         *providers: BaseProvider,
         scopes: type[BaseScope] = Scope,
         context: dict[Any, Any] | None = None,
-        lock_factory: Callable[[], AbstractContextManager[Any]] | None = Lock,
+        lock_factory: Callable[[], Lock] | None = None,
         skip_validation: bool = False,
         start_scope: BaseScope | None = None,
         validation_settings: ValidationSettings = DEFAULT_VALIDATION,
diff --git a/src/dishka/plotter/__init__.py b/src/dishka/plotter/__init__.py
new file mode 100644
index 0000000..2087784
--- /dev/null
+++ b/src/dishka/plotter/__init__.py
@@ -0,0 +1,6 @@
+__all__ = [
+    "render_d2",
+    "render_mermaid",
+]
+
+from .wrappers import render_d2, render_mermaid
diff --git a/src/dishka/plotter/d2.py b/src/dishka/plotter/d2.py
new file mode 100644
index 0000000..3e289cc
--- /dev/null
+++ b/src/dishka/plotter/d2.py
@@ -0,0 +1,94 @@
+import re
+
+from dishka.plotter.model import Group, Node, NodeType, Renderer
+
+
+class D2Renderer(Renderer):
+    def __init__(self):
+        self.names: dict[str, str] = {}
+        self.full_ids: dict[str, str] = {}
+
+    def _escape(self, line: str) -> str:
+        return re.sub(r"[^\w_.\-]", "_", line)
+
+    def _render_node(self, node: Node) -> str:
+        name = self._node_type(node) + self._escape(node.name)
+        if node.source_name:
+            source = f'    "{self._escape(node.source_name)}()": ""\n'
+        else:
+            source = ""
+
+        res = f'{node.id}: "{name}"' + "{\n"
+        res += "    shape: class\n"
+        res += source
+        for dep in node.dependencies:
+            dep_name = self._escape(self.names[dep])
+            res += f"    {dep_name}\n"
+        res += "}\n"
+        return res
+
+    def _render_node_deps(self, node: Node) -> list[str]:
+        return [
+            f"{self.full_ids[dep]} --> {self.full_ids[node.id]}"
+            for dep in node.dependencies
+        ]
+
+    def _group_type(self, group: Group) -> str:
+        return ""
+
+    def _node_type(self, node: Node) -> str:
+        if node.is_protocol:
+            prefix = "Ⓘ "
+        else:
+            prefix = ""
+        if node.type is NodeType.CONTEXT:
+            return "📥 " + prefix
+        elif node.type is NodeType.ALIAS:
+            return "🔗 " + prefix
+        return "🏭 " + prefix
+
+    def _render_group(
+            self, group: Group, name_prefix: str = "",
+    ) -> str:
+        name = (
+            self._group_type(group)
+            + name_prefix
+            + self._escape(group.name or "")
+        )
+        res = f'{group.id}: "{name}"' + "{\n"
+        for node in group.nodes:
+            res += self._render_node(node) + "\n"
+        for child in group.children:
+            res += self._render_group(child, name) + "\n"
+        res += "}\n"
+        return res
+
+    def _render_links(self, group: Group) -> str:
+        res = ""
+        for node in group.nodes:
+            for dep_str in self._render_node_deps(node):
+                res += dep_str + "\n"
+        for child in group.children:
+            res += self._render_links(child)
+        return res
+
+    def _fill_names(self, groups: list[Group], prefix: str = "") -> None:
+        for group in groups:
+            if prefix:
+                id_prefix = prefix + "." + group.id
+            else:
+                id_prefix = group.id
+            self.full_ids[group.id] = id_prefix
+            for node in group.nodes:
+                self.names[node.id] = node.name
+                self.full_ids[node.id] = id_prefix + "." + node.id
+            self._fill_names(group.children, prefix=id_prefix)
+
+    def render(self, groups: list[Group]) -> str:
+        self._fill_names(groups)
+
+        res = ""
+        for group in groups:
+            res += self._render_group(group)
+            res += self._render_links(group)
+        return res
diff --git a/src/dishka/plotter/mermaid.py b/src/dishka/plotter/mermaid.py
new file mode 100644
index 0000000..354af89
--- /dev/null
+++ b/src/dishka/plotter/mermaid.py
@@ -0,0 +1,102 @@
+import re
+
+from dishka.plotter.model import Group, Node, NodeType, Renderer
+
+HTML_TEMPLATE = """\
+<html>
+<head>
+    <meta charset="UTF-8">
+</head>
+<body>
+
+<pre class="mermaid">
+{diagram}
+</pre>
+
+<script type="module">
+    import mermaid
+    from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
+    mermaid.initialize(config);
+</script>
+</body>
+</html>
+
+"""
+
+
+class MermaidRenderer(Renderer):
+    def __init__(self):
+        self.names: dict[str, str] = {}
+
+    def _render_node(self, node: Node) -> str:
+        name = self._node_type(node) + self._escape(node.name)
+        return (
+            f'class {node.id}["{name}"]'
+            + "{\n"
+            + ((f"    {node.source_name}()\n") if node.source_name else " \n")
+            + "".join(
+                f"    {self.names[dep]}\n" for dep in node.dependencies
+            )
+            + "}\n"
+        )
+
+    def _escape(self, line: str) -> str:
+        return re.sub(r"[^\w_.\-]", "_", line)
+
+    def _render_node_deps(self, node: Node) -> list[str]:
+        return [
+            f"{dep} ..> {node.id}"
+            for dep in node.dependencies
+        ]
+
+    def _node_type(self, node: Node) -> str:
+        if node.is_protocol:
+            prefix = "Ⓘ "
+        else:
+            prefix = ""
+        if node.type is NodeType.CONTEXT:
+            return "📥 " + prefix
+        elif node.type is NodeType.ALIAS:
+            return "🔗 " + prefix
+        return "🏭 " + prefix
+
+    def _render_group(
+            self, group: Group, name_prefix: str = "",
+    ) -> str:
+        if not group.nodes and not group.children:
+            return ""
+        name = name_prefix + self._escape(group.name or "")
+        res = ""
+        if group.nodes:
+            res = f"namespace {name} {{\n"
+            for node in group.nodes:
+                res += self._render_node(node) + "\n"
+            res += "}\n"
+        for child in group.children:
+            res += self._render_group(child,  name) + "\n"
+        return res
+
+    def _render_links(self, group: Group) -> str:
+        res = ""
+        for node in group.nodes:
+            for dep_str in self._render_node_deps(node):
+                res += dep_str + "\n"
+        for child in group.children:
+            res += self._render_links(child)
+        return res
+
+    def _fill_names(self, groups: list[Group]) -> None:
+        for group in groups:
+            for node in group.nodes:
+                self.names[node.id] = node.name
+            self._fill_names(group.children)
+
+    def render(self, groups: list[Group]) -> str:
+        self._fill_names(groups)
+
+        res = "classDiagram\n"
+        res += "direction LR\n"
+        for group in groups:
+            res += self._render_group(group)
+            res += self._render_links(group)
+        return HTML_TEMPLATE.format(diagram=res)
diff --git a/src/dishka/plotter/model.py b/src/dishka/plotter/model.py
new file mode 100644
index 0000000..add3ed8
--- /dev/null
+++ b/src/dishka/plotter/model.py
@@ -0,0 +1,39 @@
+from abc import abstractmethod
+from dataclasses import dataclass
+from enum import Enum
+from typing import Protocol
+
+
+class GroupType(Enum):
+    SCOPE = "SCOPE"
+    COMPONENT = "COMPONENT"
+
+class NodeType(Enum):
+    CONTEXT = "Context"
+    FACTORY = "Factory"
+    ALIAS = "Alias"
+
+
+@dataclass
+class Node:
+    id: str
+    name: str
+    dependencies: list[str]
+    type: NodeType
+    is_protocol: bool
+    source_name: str
+
+
+@dataclass
+class Group:
+    id: str
+    name: str
+    children: list["Group"]
+    nodes: list[Node]
+    type: GroupType
+
+
+class Renderer(Protocol):
+    @abstractmethod
+    def render(self, groups: list[Group]) -> str:
+        raise NotImplementedError
diff --git a/src/dishka/plotter/transform.py b/src/dishka/plotter/transform.py
new file mode 100644
index 0000000..ca90fed
--- /dev/null
+++ b/src/dishka/plotter/transform.py
@@ -0,0 +1,111 @@
+from typing import Any
+
+from dishka import AsyncContainer, BaseScope, Container, DependencyKey
+from dishka._adaptix.type_tools import is_protocol
+from dishka.dependency_source import Factory
+from dishka.entities.factory_type import FactoryType
+from dishka.registry import Registry
+from dishka.text_rendering import get_name
+from .model import Group, GroupType, Node, NodeType
+
+
+class Transformer:
+    def __init__(self):
+        self.nodes: dict[tuple[DependencyKey, BaseScope], Node] = {}
+        self.groups: dict[Any, Group] = {}
+        self._counter = 0
+
+    def count(self, prefix: str) -> str:
+        self._counter += 1
+        return f"{prefix}{self._counter}"
+
+    def _is_empty(self, registry: Registry) -> bool:
+        for factory in registry.factories.values():
+            if factory.provides.type_hint not in (Container, AsyncContainer):
+                return False
+        return True
+
+    def _node_type(self, factory: Factory) -> NodeType:
+        if factory.type is FactoryType.ALIAS:
+            return NodeType.ALIAS
+        elif factory.type is FactoryType.CONTEXT:
+            return NodeType.CONTEXT
+        else:
+            return NodeType.FACTORY
+
+    def _make_factories(
+            self, scope: BaseScope, group: Group, registry: Registry,
+    ) -> None:
+        for key, factory in registry.factories.items():
+            group_key = (scope, key.component)
+            if group_key in self.groups:
+                component_group = self.groups[group_key]
+            else:
+                component_group = self.groups[group_key] = Group(
+                    id=self.count("component"),
+                    name=str(key.component),
+                    children=[],
+                    nodes=[],
+                    type=GroupType.COMPONENT,
+                )
+                group.children.append(component_group)
+            node_name = get_name(key.type_hint, include_module=False)
+            if key.component:
+                node_name += " " + str(key.component)
+
+            if factory.type in (FactoryType.CONTEXT, FactoryType.ALIAS):
+                source_name = ""
+            else:
+                source_name = get_name(factory.source, include_module=False)
+            node = Node(
+                id=self.count("factory"),
+                name=node_name,
+                dependencies=[],
+                type=self._node_type(factory),
+                is_protocol=is_protocol(factory.provides.type_hint),
+                source_name=source_name,
+            )
+            self.nodes[key, scope] = node
+            component_group.nodes.append(node)
+
+    def _fill_dependencies(
+            self, registry: Registry, parent_registries: list[Registry],
+    ) -> None:
+        # parent registries are passed from current scope to outer one
+        for key, factory in registry.factories.items():
+            node = self.nodes[key, registry.scope]
+            all_deps = (
+                    list(factory.dependencies)
+                    + list(factory.kw_dependencies.values())
+            )
+            for dep in all_deps:
+                for dep_registry in parent_registries:
+                    if dep in dep_registry.factories:
+                        break
+                else:
+                    continue
+                dep_node = self.nodes[dep, dep_registry.scope]
+                node.dependencies.append(dep_node.id)
+
+    def transform(self, container: Container|AsyncContainer) -> list[Group]:
+        registries = [container.registry, *container.child_registries]
+        result = []
+        for registry in registries:
+            if self._is_empty(registry):
+                continue
+            scope = registry.scope
+            group = self.groups[scope] = Group(
+                id=self.count("scope"),
+                name=str(scope),
+                children=[],
+                nodes=[],
+                type=GroupType.SCOPE,
+            )
+            result.append(group)
+            self._make_factories(scope, group, registry)
+
+        for n, registry in enumerate(registries):
+            if self._is_empty(registry):
+                continue
+            self._fill_dependencies(registry, registries[n::-1])
+        return result
diff --git a/src/dishka/plotter/wrappers.py b/src/dishka/plotter/wrappers.py
new file mode 100644
index 0000000..c82146d
--- /dev/null
+++ b/src/dishka/plotter/wrappers.py
@@ -0,0 +1,16 @@
+from dishka import AsyncContainer, Container
+from .d2 import D2Renderer
+from .mermaid import MermaidRenderer
+from .transform import Transformer
+
+
+def render_mermaid(container: AsyncContainer | Container) -> str:
+    return MermaidRenderer().render(
+        Transformer().transform(container),
+    )
+
+
+def render_d2(container: AsyncContainer | Container) -> str:
+    return D2Renderer().render(
+        Transformer().transform(container),
+    )
diff --git a/src/dishka/provider.py b/src/dishka/provider.py
index 0805179..2b9618f 100644
--- a/src/dishka/provider.py
+++ b/src/dishka/provider.py
@@ -173,13 +173,15 @@ class Provider(BaseProvider):
             self,
             *,
             source: type,
-            provides: Any,
+            provides: Any = None,
             cache: bool = True,
+            component: Component | None = None,
     ) -> CompositeDependencySource:
         composite = alias(
             source=source,
             provides=provides,
             cache=cache,
+            component=component,
         )
         self._add_dependency_sources(str(source), composite.dependency_sources)
         return composite


Test patch:
diff --git a/tests/integrations/grpcio/my_grpc_service_pb2_grpc.py b/tests/integrations/grpcio/my_grpc_service_pb2_grpc.py
index 5e13345..3e64181 100644
--- a/tests/integrations/grpcio/my_grpc_service_pb2_grpc.py
+++ b/tests/integrations/grpcio/my_grpc_service_pb2_grpc.py
@@ -24,7 +24,7 @@ except ImportError:
 
 if _version_not_supported:
     warnings.warn(  # noqa: B028
-        f"The grpc package installed is at version {GRPC_VERSION},"  # noqa: ISC003
+        f"The grpc package installed is at version {GRPC_VERSION},"
         + " but the generated code in my_grpc_service_pb2_grpc.py depends on"
         + f" grpcio>={GRPC_GENERATED_VERSION}."
         + f" Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}"  # noqa: E501
diff --git a/tests/unit/container/test_generic.py b/tests/unit/container/test_generic.py
index ca4a503..79b307f 100644
--- a/tests/unit/container/test_generic.py
+++ b/tests/unit/container/test_generic.py
@@ -21,7 +21,8 @@ class ReplaceInit(Base[str], Generic[T]):
 
 
 class A(Generic[T]):
-    def __init__(self, x: T):
+    def __init__(self, x: T, *, y: T):
+        assert x == y
         self.x = x
 
     def __eq__(self, other):
@@ -93,7 +94,7 @@ def test_bare_generic_method():
 
         @provide
         def a(self) -> A:
-            return A(42)
+            return A(42, y=42)
 
     container = make_container(MyProvider())
     a = container.get(A)
@@ -111,7 +112,7 @@ def test_generic_func():
 
         @provide
         def a(self, param: T) -> A[T]:
-            return A(param)
+            return A(param, y=param)
 
     container = make_container(MyProvider())
     a = container.get(A[int])
@@ -185,7 +186,7 @@ def parametrized_param_func(param: A[T]) -> EventEmitter[T]:
 def test_func_with_generic_params():
     provider = Provider(scope=Scope.APP)
     provider.provide(parametrized_param_func)
-    provider.provide(lambda: A(42), provides=A[T])
+    provider.provide(lambda: A(42, y=42), provides=A[T])
     container = make_container(provider)
     assert container.get(EventEmitter[int]) == 42
 
@@ -201,8 +202,8 @@ def type_var_decorator(
 
 def test_passing_type_var_decorator():
     provider = Provider(scope=Scope.APP)
-    provider.provide(lambda: A(42), provides=int)
+    provider.provide(lambda: A(42, y=42), provides=int)
     provider.provide(lambda: 1, provides=EventEmitter[T])
     provider.decorate(type_var_decorator)
     container = make_container(provider)
-    assert container.get(int) == (int, 1, A(42))
+    assert container.get(int) == (int, 1, A(42, y=42))
diff --git a/tests/unit/plotter/__init__.py b/tests/unit/plotter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit/plotter/test_transform.py b/tests/unit/plotter/test_transform.py
new file mode 100644
index 0000000..b61d23f
--- /dev/null
+++ b/tests/unit/plotter/test_transform.py
@@ -0,0 +1,250 @@
+from typing import Protocol
+
+from dishka import Provider, Scope, make_container
+from dishka.plotter.model import Group, GroupType, Node, NodeType
+from dishka.plotter.transform import Transformer
+
+
+def test_empty():
+    container = make_container()
+    res = Transformer().transform(container)
+    assert not res
+
+
+class A:
+    pass
+
+
+class B:
+    def __init__(self, a: A):
+        pass
+
+
+class P(Protocol):
+    pass
+
+
+EXPECTED_GRAPH = [
+    Group(
+        id="scope1",
+        name="Scope.APP",
+        children=[
+            Group(
+                id="component2",
+                name="",
+                children=[],
+                nodes=[
+                    Node(
+                        id="factory3",
+                        name="Container",
+                        dependencies=[],
+                        type=NodeType.CONTEXT,
+                        is_protocol=False,
+                        source_name="",
+                    ),
+                    Node(
+                        id="factory4",
+                        name="A",
+                        dependencies=[],
+                        type=NodeType.FACTORY,
+                        is_protocol=False,
+                        source_name="A",
+                    ),
+                    Node(
+                        id="factory5",
+                        name="B",
+                        dependencies=["factory4"],
+                        type=NodeType.FACTORY,
+                        is_protocol=False,
+                        source_name="B",
+                    ),
+                    Node(
+                        id="factory6",
+                        name="P",
+                        dependencies=[],
+                        type=NodeType.FACTORY,
+                        is_protocol=True,
+                        source_name="test_deps.<locals>.<lambda>",
+                    ),
+                    Node(
+                        id="factory7",
+                        name="float",
+                        dependencies=["factory6"],
+                        type=NodeType.ALIAS,
+                        is_protocol=False,
+                        source_name="",
+                    ),
+                ],
+                type=GroupType.COMPONENT,
+            ),
+        ],
+        nodes=[],
+        type=GroupType.SCOPE,
+    ),
+]
+
+
+def test_deps():
+    provider = Provider(scope=Scope.APP)
+    provider.provide(A)
+    provider.provide(B)
+    provider.provide(lambda: 0, provides=P)
+    provider.alias(source=P, provides=float)
+    container = make_container(provider)
+    res = Transformer().transform(container)
+    assert res == EXPECTED_GRAPH
+
+
+def cycle1(x: int) -> float:
+    pass
+
+
+def cycle2(x: float) -> int:
+    pass
+
+
+EXPECTED_CYCLE_GRAPH = [
+    Group(
+        id="scope1",
+        name="Scope.APP",
+        children=[
+            Group(
+                id="component2",
+                name="",
+                children=[],
+                nodes=[
+                    Node(
+                        id="factory3",
+                        name="Container",
+                        dependencies=[],
+                        type=NodeType.CONTEXT,
+                        is_protocol=False,
+                        source_name="",
+                    ),
+                    Node(
+                        id="factory4",
+                        name="B",
+                        dependencies=[],
+                        type=NodeType.FACTORY,
+                        is_protocol=False,
+                        source_name="B",
+                    ),
+                    Node(
+                        id="factory5",
+                        name="float",
+                        dependencies=["factory6"],
+                        type=NodeType.FACTORY,
+                        is_protocol=False,
+                        source_name="cycle1",
+                    ),
+                    Node(
+                        id="factory6",
+                        name="int",
+                        dependencies=["factory5"],
+                        type=NodeType.FACTORY,
+                        is_protocol=False,
+                        source_name="cycle2",
+                    ),
+                ],
+                type=GroupType.COMPONENT,
+            ),
+        ],
+        nodes=[],
+        type=GroupType.SCOPE,
+    ),
+]
+
+
+def test_invalid():
+    provider = Provider(scope=Scope.APP)
+    provider.provide(B)
+    provider.provide(cycle1)
+    provider.provide(cycle2)
+    container = make_container(provider, skip_validation=True)
+    res = Transformer().transform(container)
+    assert res == EXPECTED_CYCLE_GRAPH
+
+
+COMPONENT = "comp1"
+
+COMPONENT_GRAPH = [
+    Group(
+        id="scope1",
+        name="Scope.APP",
+        children=[
+            Group(
+                id="component2",
+                name="",
+                children=[],
+                nodes=[
+                    Node(
+                        id="factory3",
+                        name="Container",
+                        dependencies=[],
+                        type=NodeType.CONTEXT,
+                        is_protocol=False,
+                        source_name="",
+                    ),
+                    Node(
+                        id="factory7",
+                        name="B",
+                        dependencies=[
+                            "factory8",
+                        ],
+                        type=NodeType.FACTORY,
+                        is_protocol=False,
+                        source_name="B",
+                    ),
+                    Node(
+                        id="factory8",
+                        name="A",
+                        dependencies=[
+                            "factory6",
+                        ],
+                        type=NodeType.ALIAS,
+                        is_protocol=False,
+                        source_name="",
+                    ),
+                ],
+                type=GroupType.COMPONENT,
+            ),
+            Group(
+                id="component4",
+                name="comp1",
+                children=[],
+                nodes=[
+                    Node(
+                        id="factory5",
+                        name="Container comp1",
+                        dependencies=["factory3"],
+                        type=NodeType.ALIAS,
+                        is_protocol=False,
+                        source_name="",
+                    ),
+                    Node(
+                        id="factory6",
+                        name="A comp1",
+                        dependencies=[],
+                        type=NodeType.FACTORY,
+                        is_protocol=False,
+                        source_name="A",
+                    ),
+                ],
+                type=GroupType.COMPONENT,
+            ),
+        ],
+        nodes=[],
+        type=GroupType.SCOPE,
+    ),
+]
+
+
+def test_components():
+    component_provider = Provider(scope=Scope.APP, component=COMPONENT)
+    component_provider.provide(source=A)
+    provider = Provider(scope=Scope.APP)
+    provider.alias(source=A, component=COMPONENT)
+    provider.provide(B)
+    container = make_container(component_provider, provider)
+    res = Transformer().transform(container)
+    assert res == COMPONENT_GRAPH
diff --git a/tests/unit/plotter/test_wrappers.py b/tests/unit/plotter/test_wrappers.py
new file mode 100644
index 0000000..76272ae
--- /dev/null
+++ b/tests/unit/plotter/test_wrappers.py
@@ -0,0 +1,31 @@
+import pytest
+
+from dishka import (
+    Provider,
+    Scope,
+    alias,
+    make_async_container,
+    make_container,
+    provide,
+)
+from dishka.plotter import render_d2, render_mermaid
+
+
+class MyProvider(Provider):
+    component = "XXX"
+    @provide(scope=Scope.APP)
+    def foo(self) -> int:
+        return 1
+
+    float_alias = alias(source=int, provides=float)
+
+
+@pytest.mark.parametrize("container", [
+    make_container(MyProvider()),
+    make_async_container(MyProvider()),
+])
+@pytest.mark.parametrize("renderer", [
+    render_d2, render_mermaid,
+])
+def test_wrapper(container, renderer):
+    assert renderer(container)