Browse Source

Merge 4daa79c1d6 into de52317c58

pull/960/merge
Vadim Kharitonov 1 month ago
committed by GitHub
parent
commit
3438809070
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
9 changed files with 273 additions and 239 deletions
  1. +2
    -0
      .github/workflows/test.yml
  2. +124
    -117
      diagrams/__init__.py
  3. +0
    -5
      diagrams/aws/general.py
  4. +19
    -17
      diagrams/c4/__init__.py
  5. +8
    -4
      diagrams/custom/__init__.py
  6. +54
    -36
      poetry.lock
  7. +9
    -0
      pyproject.toml
  8. +13
    -14
      tests/test_c4.py
  9. +44
    -46
      tests/test_diagram.py

+ 2
- 0
.github/workflows/test.yml View File

@@ -33,3 +33,5 @@ jobs:
run: | run: |
poetry install poetry install
poetry run python -m unittest -v tests/*.py poetry run python -m unittest -v tests/*.py
- name: Run mypy
run: poetry run mypy

+ 124
- 117
diagrams/__init__.py View File

@@ -1,49 +1,44 @@
import contextvars
import os import os
import uuid import uuid
from contextvars import ContextVar
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Union
from typing import Any, AnyStr, cast, Dict, List, Mapping, Optional, Tuple, Type, Union
from types import TracebackType


from graphviz import Digraph
from graphviz import Digraph # type: ignore[import]


# Global contexts for a diagrams and a cluster. # Global contexts for a diagrams and a cluster.
# #
# These global contexts are for letting the clusters and nodes know # These global contexts are for letting the clusters and nodes know
# where context they are belong to. So the all clusters and nodes does # where context they are belong to. So the all clusters and nodes does
# not need to specify the current diagrams or cluster via parameters. # not need to specify the current diagrams or cluster via parameters.
__diagram = contextvars.ContextVar("diagrams")
__cluster = contextvars.ContextVar("cluster")
__diagram: ContextVar[Optional["Diagram"]] = ContextVar("diagrams")
__cluster: ContextVar[Optional["Cluster"]] = ContextVar("cluster")




def getdiagram() -> "Diagram":
try:
return __diagram.get()
except LookupError:
return None
def getdiagram() -> Optional["Diagram"]:
return __diagram.get(None)




def setdiagram(diagram: "Diagram"):
def setdiagram(diagram: Optional["Diagram"]) -> None:
__diagram.set(diagram) __diagram.set(diagram)




def getcluster() -> "Cluster":
try:
return __cluster.get()
except LookupError:
return None
def getcluster() -> Optional["Cluster"]:
return __cluster.get(None)




def setcluster(cluster: "Cluster"):
def setcluster(cluster: Optional["Cluster"]) -> None:
__cluster.set(cluster) __cluster.set(cluster)




class Diagram: class Diagram:
__directions = ("TB", "BT", "LR", "RL")
__curvestyles = ("ortho", "curved")
__outformats = ("png", "jpg", "svg", "pdf", "dot")
__directions: Tuple[str, ...] = ("TB", "BT", "LR", "RL")
__curvestyles: Tuple[str, ...] = ("ortho", "curved")
__outformats: Tuple[str, ...] = ("png", "jpg", "svg", "pdf", "dot")


# fmt: off # fmt: off
_default_graph_attrs = {
_default_graph_attrs: Mapping[str, str] = {
"pad": "2.0", "pad": "2.0",
"splines": "ortho", "splines": "ortho",
"nodesep": "0.60", "nodesep": "0.60",
@@ -52,7 +47,7 @@ class Diagram:
"fontsize": "15", "fontsize": "15",
"fontcolor": "#2D3436", "fontcolor": "#2D3436",
} }
_default_node_attrs = {
_default_node_attrs: Mapping[str, str] = {
"shape": "box", "shape": "box",
"style": "rounded", "style": "rounded",
"fixedsize": "true", "fixedsize": "true",
@@ -68,7 +63,7 @@ class Diagram:
"fontsize": "13", "fontsize": "13",
"fontcolor": "#2D3436", "fontcolor": "#2D3436",
} }
_default_edge_attrs = {
_default_edge_attrs: Mapping[str, str] = {
"color": "#7B8894", "color": "#7B8894",
} }


@@ -82,13 +77,13 @@ class Diagram:
filename: str = "", filename: str = "",
direction: str = "LR", direction: str = "LR",
curvestyle: str = "ortho", curvestyle: str = "ortho",
outformat: str = "png",
outformat: Union[List[str], str] = "png",
autolabel: bool = False, autolabel: bool = False,
show: bool = True, show: bool = True,
strict: bool = False, strict: bool = False,
graph_attr: Optional[dict] = None,
node_attr: Optional[dict] = None,
edge_attr: Optional[dict] = None,
graph_attr: Optional[Mapping[str, Any]] = None,
node_attr: Optional[Mapping[str, Any]] = None,
edge_attr: Optional[Mapping[str, Any]] = None,
): ):
"""Diagram represents a global diagrams context. """Diagram represents a global diagrams context.


@@ -116,8 +111,8 @@ class Diagram:
filename = "diagrams_image" filename = "diagrams_image"
elif not filename: elif not filename:
filename = "_".join(self.name.split()).lower() filename = "_".join(self.name.split()).lower()
self.filename = filename
self.dot = Digraph(self.name, filename=self.filename, strict=strict)
self.filename: str = filename
self.dot: Digraph = Digraph(self.name, filename=self.filename, strict=strict)


# Set attributes. # Set attributes.
for k, v in self._default_graph_attrs.items(): for k, v in self._default_graph_attrs.items():
@@ -143,7 +138,7 @@ class Diagram:
else: else:
if not self._validate_outformat(outformat): if not self._validate_outformat(outformat):
raise ValueError(f'"{outformat}" is not a valid output format') raise ValueError(f'"{outformat}" is not a valid output format')
self.outformat = outformat
self.outformat: Union[List[str], str] = outformat


# Merge passed in attributes # Merge passed in attributes
self.dot.graph_attr.update(graph_attr) self.dot.graph_attr.update(graph_attr)
@@ -156,18 +151,23 @@ class Diagram:
def __str__(self) -> str: def __str__(self) -> str:
return str(self.dot) return str(self.dot)


def __enter__(self):
def __enter__(self) -> "Diagram":
setdiagram(self) setdiagram(self)
return self return self


def __exit__(self, exc_type, exc_value, traceback):
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
self.render() self.render()
# Remove the graphviz file leaving only the image. # Remove the graphviz file leaving only the image.
os.remove(self.filename) os.remove(self.filename)
setdiagram(None) setdiagram(None)


def _repr_png_(self):
return self.dot.pipe(format="png")
def _repr_png(self) -> AnyStr:
return cast(AnyStr, self.dot.pipe(format="png"))


def _validate_direction(self, direction: str) -> bool: def _validate_direction(self, direction: str) -> bool:
return direction.upper() in self.__directions return direction.upper() in self.__directions
@@ -178,7 +178,7 @@ class Diagram:
def _validate_outformat(self, outformat: str) -> bool: def _validate_outformat(self, outformat: str) -> bool:
return outformat.lower() in self.__outformats return outformat.lower() in self.__outformats


def node(self, nodeid: str, label: str, **attrs) -> None:
def node(self, nodeid: str, label: str, **attrs: Dict[Any, Any]) -> None:
"""Create a new node.""" """Create a new node."""
self.dot.node(nodeid, label=label, **attrs) self.dot.node(nodeid, label=label, **attrs)


@@ -199,11 +199,11 @@ class Diagram:




class Cluster: class Cluster:
__directions = ("TB", "BT", "LR", "RL")
__bgcolors = ("#E5F5FD", "#EBF3E7", "#ECE8F6", "#FDF7E3")
__directions: Tuple[str, ...] = ("TB", "BT", "LR", "RL")
__bgcolors: Tuple[str, ...] = ("#E5F5FD", "#EBF3E7", "#ECE8F6", "#FDF7E3")


# fmt: off # fmt: off
_default_graph_attrs = {
_default_graph_attrs: Mapping[str, str] = {
"shape": "box", "shape": "box",
"style": "rounded", "style": "rounded",
"labeljust": "l", "labeljust": "l",
@@ -221,7 +221,7 @@ class Cluster:
self, self,
label: str = "cluster", label: str = "cluster",
direction: str = "LR", direction: str = "LR",
graph_attr: Optional[dict] = None,
graph_attr: Optional[Mapping[str, Any]] = None,
): ):
"""Cluster represents a cluster context. """Cluster represents a cluster context.


@@ -231,10 +231,10 @@ class Cluster:
""" """
if graph_attr is None: if graph_attr is None:
graph_attr = {} graph_attr = {}
self.label = label
self.name = "cluster_" + self.label
self.label: str = label
self.name: str = f"cluster_{self.label}"


self.dot = Digraph(self.name)
self.dot: Digraph = Digraph(self.name)


# Set attributes. # Set attributes.
for k, v in self._default_graph_attrs.items(): for k, v in self._default_graph_attrs.items():
@@ -246,24 +246,30 @@ class Cluster:
self.dot.graph_attr["rankdir"] = direction self.dot.graph_attr["rankdir"] = direction


# Node must be belong to a diagrams. # Node must be belong to a diagrams.
self._diagram = getdiagram()
if self._diagram is None:
diagram = getdiagram()
if diagram is None:
raise EnvironmentError("Global diagrams context not set up") raise EnvironmentError("Global diagrams context not set up")
self._parent = getcluster()
self._diagram: Diagram = diagram
self._parent: Optional["Cluster"] = getcluster()


# Set cluster depth for distinguishing the background color # Set cluster depth for distinguishing the background color
self.depth = self._parent.depth + 1 if self._parent else 0
self.depth: int = self._parent.depth + 1 if self._parent else 0
coloridx = self.depth % len(self.__bgcolors) coloridx = self.depth % len(self.__bgcolors)
self.dot.graph_attr["bgcolor"] = self.__bgcolors[coloridx] self.dot.graph_attr["bgcolor"] = self.__bgcolors[coloridx]


# Merge passed in attributes # Merge passed in attributes
self.dot.graph_attr.update(graph_attr) self.dot.graph_attr.update(graph_attr)


def __enter__(self):
def __enter__(self) -> "Cluster":
setcluster(self) setcluster(self)
return self return self


def __exit__(self, exc_type, exc_value, traceback):
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
if self._parent: if self._parent:
self._parent.subgraph(self.dot) self._parent.subgraph(self.dot)
else: else:
@@ -273,7 +279,7 @@ class Cluster:
def _validate_direction(self, direction: str) -> bool: def _validate_direction(self, direction: str) -> bool:
return direction.upper() in self.__directions return direction.upper() in self.__directions


def node(self, nodeid: str, label: str, **attrs) -> None:
def node(self, nodeid: str, label: str, **attrs: Dict[Any, Any]) -> None:
"""Create a new node in the cluster.""" """Create a new node in the cluster."""
self.dot.node(nodeid, label=label, **attrs) self.dot.node(nodeid, label=label, **attrs)


@@ -284,32 +290,33 @@ class Cluster:
class Node: class Node:
"""Node represents a node for a specific backend service.""" """Node represents a node for a specific backend service."""


_provider = None
_type = None
_provider: Optional[str] = None
_type: Optional[str] = None


_icon_dir = None
_icon = None
_icon_dir: Optional[str] = None
_icon: Optional[str] = None


_height = 1.9
_height: float = 1.9


def __init__(self, label: str = "", *, nodeid: str = None, **attrs: Dict):
def __init__(self, label: str = "", *, nodeid: Optional[str] = None, **attrs: Dict[Any, Any]):
"""Node represents a system component. """Node represents a system component.


:param label: Node label. :param label: Node label.
""" """
# Generates an ID for identifying a node, unless specified # Generates an ID for identifying a node, unless specified
self._id = nodeid or self._rand_id()
self.label = label
self._id: str = nodeid or self._rand_id()
self.label: str = label


# Node must be belong to a diagrams. # Node must be belong to a diagrams.
self._diagram = getdiagram()
if self._diagram is None:
diagram = getdiagram()
if diagram is None:
raise EnvironmentError("Global diagrams context not set up") raise EnvironmentError("Global diagrams context not set up")
self._diagram: Diagram = diagram


if self._diagram.autolabel: if self._diagram.autolabel:
prefix = self.__class__.__name__ prefix = self.__class__.__name__
if self.label: if self.label:
self.label = prefix + "\n" + self.label
self.label = f"{prefix}\n{self.label}"
else: else:
self.label = prefix self.label = prefix


@@ -318,16 +325,17 @@ class Node:
# that label being spanned between icon image and white space. # that label being spanned between icon image and white space.
# Increase the height by the number of new lines included in the label. # Increase the height by the number of new lines included in the label.
padding = 0.4 * (self.label.count('\n')) padding = 0.4 * (self.label.count('\n'))
self._attrs = {
icon = self._load_icon()
self._attrs: Dict[str, Any] = {
"shape": "none", "shape": "none",
"height": str(self._height + padding), "height": str(self._height + padding),
"image": self._load_icon(),
} if self._icon else {}
"image": icon,
} if icon is not None else {}


# fmt: on # fmt: on
self._attrs.update(attrs) self._attrs.update(attrs)


self._cluster = getcluster()
self._cluster: Optional[Cluster] = getcluster()


# If a node is in the cluster context, add it to cluster. # If a node is in the cluster context, add it to cluster.
if self._cluster: if self._cluster:
@@ -335,23 +343,22 @@ class Node:
else: else:
self._diagram.node(self._id, self.label, **self._attrs) self._diagram.node(self._id, self.label, **self._attrs)


def __repr__(self):
_name = self.__class__.__name__
return f"<{self._provider}.{self._type}.{_name}>"
def __repr__(self) -> str:
name = self.__class__.__name__
return f"<{self._provider}.{self._type}.{name}>"


def __sub__(self, other: Union["Node", List["Node"], "Edge"]):
def __sub__(self, other: Union["Node", List["Node"], "Edge"]) -> Union["Node", List["Node"], "Edge"]:
"""Implement Self - Node, Self - [Nodes] and Self - Edge.""" """Implement Self - Node, Self - [Nodes] and Self - Edge."""
if isinstance(other, list): if isinstance(other, list):
for node in other: for node in other:
self.connect(node, Edge(self)) self.connect(node, Edge(self))
return other return other
elif isinstance(other, Node):
if isinstance(other, Node):
return self.connect(other, Edge(self)) return self.connect(other, Edge(self))
else:
other.node = self
return other
other.node = self
return other


def __rsub__(self, other: Union[List["Node"], List["Edge"]]):
def __rsub__(self, other: Union[List["Node"], List["Edge"]]) -> "Node":
"""Called for [Nodes] and [Edges] - Self because list don't have __sub__ operators.""" """Called for [Nodes] and [Edges] - Self because list don't have __sub__ operators."""
for o in other: for o in other:
if isinstance(o, Edge): if isinstance(o, Edge):
@@ -360,32 +367,30 @@ class Node:
o.connect(self, Edge(self)) o.connect(self, Edge(self))
return self return self


def __rshift__(self, other: Union["Node", List["Node"], "Edge"]):
def __rshift__(self, other: Union["Node", List["Node"], "Edge"]) -> Union["Node", List["Node"], "Edge"]:
"""Implements Self >> Node, Self >> [Nodes] and Self Edge.""" """Implements Self >> Node, Self >> [Nodes] and Self Edge."""
if isinstance(other, list): if isinstance(other, list):
for node in other: for node in other:
self.connect(node, Edge(self, forward=True)) self.connect(node, Edge(self, forward=True))
return other return other
elif isinstance(other, Node):
if isinstance(other, Node):
return self.connect(other, Edge(self, forward=True)) return self.connect(other, Edge(self, forward=True))
else:
other.forward = True
other.node = self
return other
other.forward = True
other.node = self
return other


def __lshift__(self, other: Union["Node", List["Node"], "Edge"]):
def __lshift__(self, other: Union["Node", List["Node"], "Edge"]) -> Union["Node", List["Node"], "Edge"]:
"""Implements Self << Node, Self << [Nodes] and Self << Edge.""" """Implements Self << Node, Self << [Nodes] and Self << Edge."""
if isinstance(other, list): if isinstance(other, list):
for node in other: for node in other:
self.connect(node, Edge(self, reverse=True)) self.connect(node, Edge(self, reverse=True))
return other return other
elif isinstance(other, Node):
if isinstance(other, Node):
return self.connect(other, Edge(self, reverse=True)) return self.connect(other, Edge(self, reverse=True))
else:
other.reverse = True
return other.connect(self)
other.reverse = True
return other.connect(self)


def __rrshift__(self, other: Union[List["Node"], List["Edge"]]):
def __rrshift__(self, other: Union[List["Node"], List["Edge"]]) -> "Node":
"""Called for [Nodes] and [Edges] >> Self because list don't have __rshift__ operators.""" """Called for [Nodes] and [Edges] >> Self because list don't have __rshift__ operators."""
for o in other: for o in other:
if isinstance(o, Edge): if isinstance(o, Edge):
@@ -395,7 +400,7 @@ class Node:
o.connect(self, Edge(self, forward=True)) o.connect(self, Edge(self, forward=True))
return self return self


def __rlshift__(self, other: Union[List["Node"], List["Edge"]]):
def __rlshift__(self, other: Union[List["Node"], List["Edge"]]) -> "Node":
"""Called for [Nodes] << Self because list of Nodes don't have __lshift__ operators.""" """Called for [Nodes] << Self because list of Nodes don't have __lshift__ operators."""
for o in other: for o in other:
if isinstance(o, Edge): if isinstance(o, Edge):
@@ -406,11 +411,11 @@ class Node:
return self return self


@property @property
def nodeid(self):
def nodeid(self) -> str:
return self._id return self._id


# TODO: option for adding flow description to the connection edge # TODO: option for adding flow description to the connection edge
def connect(self, node: "Node", edge: "Edge"):
def connect(self, node: "Node", edge: "Edge") -> "Node":
"""Connect to other node. """Connect to other node.


:param node: Other node instance. :param node: Other node instance.
@@ -426,18 +431,19 @@ class Node:
return node return node


@staticmethod @staticmethod
def _rand_id():
def _rand_id() -> str:
return uuid.uuid4().hex return uuid.uuid4().hex


def _load_icon(self):
basedir = Path(os.path.abspath(os.path.dirname(__file__)))
return os.path.join(basedir.parent, self._icon_dir, self._icon)
def _load_icon(self) -> Optional[str]:
if self._icon_dir is None or self._icon is None:
return None
return str(Path(__file__).parent / self._icon_dir / self._icon)




class Edge: class Edge:
"""Edge represents an edge between two nodes.""" """Edge represents an edge between two nodes."""


_default_edge_attrs = {
_default_edge_attrs: Mapping[str, str] = {
"fontcolor": "#2D3436", "fontcolor": "#2D3436",
"fontname": "Sans-Serif", "fontname": "Sans-Serif",
"fontsize": "13", "fontsize": "13",
@@ -445,13 +451,13 @@ class Edge:


def __init__( def __init__(
self, self,
node: "Node" = None,
node: Optional["Node"] = None,
forward: bool = False, forward: bool = False,
reverse: bool = False, reverse: bool = False,
label: str = "", label: str = "",
color: str = "", color: str = "",
style: str = "", style: str = "",
**attrs: Dict,
**attrs: Dict[Any, Any],
): ):
"""Edge represents an edge between two nodes. """Edge represents an edge between two nodes.


@@ -466,11 +472,11 @@ class Edge:
if node is not None: if node is not None:
assert isinstance(node, Node) assert isinstance(node, Node)


self.node = node
self.forward = forward
self.reverse = reverse
self.node: Optional[Node] = node
self.forward: bool = forward
self.reverse: bool = reverse


self._attrs = {}
self._attrs: Dict[str, Any] = {}


# Set attributes. # Set attributes.
for k, v in self._default_edge_attrs.items(): for k, v in self._default_edge_attrs.items():
@@ -486,7 +492,7 @@ class Edge:
self._attrs["style"] = style self._attrs["style"] = style
self._attrs.update(attrs) self._attrs.update(attrs)


def __sub__(self, other: Union["Node", "Edge", List["Node"]]):
def __sub__(self, other: Union["Node", "Edge", List["Node"]]) -> Union["Node", "Edge", List["Node"]]:
"""Implement Self - Node or Edge and Self - [Nodes]""" """Implement Self - Node or Edge and Self - [Nodes]"""
return self.connect(other) return self.connect(other)


@@ -494,12 +500,12 @@ class Edge:
"""Called for [Nodes] or [Edges] - Self because list don't have __sub__ operators.""" """Called for [Nodes] or [Edges] - Self because list don't have __sub__ operators."""
return self.append(other) return self.append(other)


def __rshift__(self, other: Union["Node", "Edge", List["Node"]]):
def __rshift__(self, other: Union["Node", "Edge", List["Node"]]) -> Union["Node", "Edge", List["Node"]]:
"""Implements Self >> Node or Edge and Self >> [Nodes].""" """Implements Self >> Node or Edge and Self >> [Nodes]."""
self.forward = True self.forward = True
return self.connect(other) return self.connect(other)


def __lshift__(self, other: Union["Node", "Edge", List["Node"]]):
def __lshift__(self, other: Union["Node", "Edge", List["Node"]]) -> Union["Node", "Edge", List["Node"]]:
"""Implements Self << Node or Edge and Self << [Nodes].""" """Implements Self << Node or Edge and Self << [Nodes]."""
self.reverse = True self.reverse = True
return self.connect(other) return self.connect(other)
@@ -512,35 +518,36 @@ class Edge:
"""Called for [Nodes] or [Edges] << Self because list of Edges don't have __lshift__ operators.""" """Called for [Nodes] or [Edges] << Self because list of Edges don't have __lshift__ operators."""
return self.append(other, reverse=True) return self.append(other, reverse=True)


def append(self, other: Union[List["Node"], List["Edge"]], forward=None, reverse=None) -> List["Edge"]:
def append(
self, other: Union[List["Node"], List["Edge"]], forward: Optional[bool] = None, reverse: Optional[bool] = None
) -> List["Edge"]:
result = [] result = []
for o in other: for o in other:
if isinstance(o, Edge): if isinstance(o, Edge):
o.forward = forward if forward else o.forward o.forward = forward if forward else o.forward
o.reverse = forward if forward else o.reverse
o.reverse = reverse if reverse else o.reverse
self._attrs = o.attrs.copy() self._attrs = o.attrs.copy()
result.append(o) result.append(o)
else: else:
result.append(Edge(o, forward=forward, reverse=reverse, **self._attrs))
result.append(Edge(o, forward=bool(forward), reverse=bool(reverse), **self._attrs))
return result return result


def connect(self, other: Union["Node", "Edge", List["Node"]]):
def connect(self, other: Union["Node", "Edge", List["Node"]]) -> Union["Node", "Edge", List["Node"]]:
if isinstance(other, list): if isinstance(other, list):
for node in other:
self.node.connect(node, self)
if self.node is not None:
for node in other:
self.node.connect(node, self)
return other return other
elif isinstance(other, Edge):
if isinstance(other, Edge):
self._attrs = other._attrs.copy() self._attrs = other._attrs.copy()
return self return self
else:
if self.node is not None:
return self.node.connect(other, self)
else:
self.node = other
return self
if self.node is not None:
return self.node.connect(other, self)
self.node = other
return self


@property @property
def attrs(self) -> Dict:
def attrs(self) -> Dict[str, Any]:
if self.forward and self.reverse: if self.forward and self.reverse:
direction = "both" direction = "both"
elif self.forward: elif self.forward:
@@ -552,4 +559,4 @@ class Edge:
return {**self._attrs, "dir": direction} return {**self._attrs, "dir": direction}




Group = Cluster
Group: Type[Cluster] = Cluster

+ 0
- 5
diagrams/aws/general.py View File

@@ -102,8 +102,3 @@ class User(_General):


class Users(_General): class Users(_General):
_icon = "users.png" _icon = "users.png"


# Aliases

OfficeBuilding = GenericOfficeBuilding

+ 19
- 17
diagrams/c4/__init__.py View File

@@ -3,10 +3,12 @@ A set of nodes and edges to visualize software architecture using the C4 model.
""" """
import html import html
import textwrap import textwrap
from typing import Any, Dict

from diagrams import Cluster, Node, Edge from diagrams import Cluster, Node, Edge




def _format_node_label(name, key, description):
def _format_node_label(name: str, key: str, description: str) -> str:
"""Create a graphviz label string for a C4 node""" """Create a graphviz label string for a C4 node"""
title = f'<font point-size="12"><b>{html.escape(name)}</b></font><br/>' title = f'<font point-size="12"><b>{html.escape(name)}</b></font><br/>'
subtitle = f'<font point-size="9">[{html.escape(key)}]<br/></font>' if key else "" subtitle = f'<font point-size="9">[{html.escape(key)}]<br/></font>' if key else ""
@@ -14,7 +16,7 @@ def _format_node_label(name, key, description):
return f"<{title}{subtitle}{text}>" return f"<{title}{subtitle}{text}>"




def _format_description(description):
def _format_description(description: str) -> str:
""" """
Formats the description string so it fits into the C4 nodes. Formats the description string so it fits into the C4 nodes.


@@ -29,7 +31,7 @@ def _format_description(description):
return "<br/>".join(lines) return "<br/>".join(lines)




def _format_edge_label(description):
def _format_edge_label(description: str) -> str:
"""Create a graphviz label string for a C4 edge""" """Create a graphviz label string for a C4 edge"""
wrapper = textwrap.TextWrapper(width=24, max_lines=3) wrapper = textwrap.TextWrapper(width=24, max_lines=3)
lines = [html.escape(line) for line in wrapper.wrap(description)] lines = [html.escape(line) for line in wrapper.wrap(description)]
@@ -37,9 +39,9 @@ def _format_edge_label(description):
return f'<<font point-size="10">{text}</font>>' return f'<<font point-size="10">{text}</font>>'




def C4Node(name, technology="", description="", type="Container", **kwargs):
def C4Node(name: str, technology: str = "", description: str = "", type: str = "Container", **kwargs: Dict[str, Any]) -> Node:
key = f"{type}: {technology}" if technology else type key = f"{type}: {technology}" if technology else type
node_attributes = {
node_attributes: Dict[str, Any] = {
"label": _format_node_label(name, key, description), "label": _format_node_label(name, key, description),
"labelloc": "c", "labelloc": "c",
"shape": "rect", "shape": "rect",
@@ -57,8 +59,8 @@ def C4Node(name, technology="", description="", type="Container", **kwargs):
return Node(**node_attributes) return Node(**node_attributes)




def Container(name, technology="", description="", **kwargs):
container_attributes = {
def Container(name: str, technology: str = "", description: str = "", **kwargs: Dict[str, Any]) -> Node:
container_attributes: Dict[str, Any] = {
"name": name, "name": name,
"technology": technology, "technology": technology,
"description": description, "description": description,
@@ -68,8 +70,8 @@ def Container(name, technology="", description="", **kwargs):
return C4Node(**container_attributes) return C4Node(**container_attributes)




def Database(name, technology="", description="", **kwargs):
database_attributes = {
def Database(name: str, technology: str = "", description: str = "", **kwargs: Dict[str, Any]) -> Node:
database_attributes: Dict[str, Any] = {
"name": name, "name": name,
"technology": technology, "technology": technology,
"description": description, "description": description,
@@ -81,8 +83,8 @@ def Database(name, technology="", description="", **kwargs):
return C4Node(**database_attributes) return C4Node(**database_attributes)




def System(name, description="", external=False, **kwargs):
system_attributes = {
def System(name: str, description: str = "", external: bool = False, **kwargs: Dict[str, Any]) -> Node:
system_attributes: Dict[str, Any] = {
"name": name, "name": name,
"description": description, "description": description,
"type": "External System" if external else "System", "type": "External System" if external else "System",
@@ -92,8 +94,8 @@ def System(name, description="", external=False, **kwargs):
return C4Node(**system_attributes) return C4Node(**system_attributes)




def Person(name, description="", external=False, **kwargs):
person_attributes = {
def Person(name: str, description: str = "", external: bool = False, **kwargs: Dict[str, Any]) -> Node:
person_attributes: Dict[str, Any] = {
"name": name, "name": name,
"description": description, "description": description,
"type": "External Person" if external else "Person", "type": "External Person" if external else "Person",
@@ -104,8 +106,8 @@ def Person(name, description="", external=False, **kwargs):
return C4Node(**person_attributes) return C4Node(**person_attributes)




def SystemBoundary(name, **kwargs):
graph_attributes = {
def SystemBoundary(name: str, **kwargs: Dict[str, Any]) -> Cluster:
graph_attributes: Dict[str, Any] = {
"label": html.escape(name), "label": html.escape(name),
"bgcolor": "white", "bgcolor": "white",
"margin": "16", "margin": "16",
@@ -115,8 +117,8 @@ def SystemBoundary(name, **kwargs):
return Cluster(name, graph_attr=graph_attributes) return Cluster(name, graph_attr=graph_attributes)




def Relationship(label="", **kwargs):
edge_attributes = {
def Relationship(label: str = "", **kwargs: Dict[str, Any]) -> Edge:
edge_attributes: Dict[str, Any] = {
"style": "dashed", "style": "dashed",
"color": "gray60", "color": "gray60",
"label": _format_edge_label(label) if label else "", "label": _format_edge_label(label) if label else "",


+ 8
- 4
diagrams/custom/__init__.py View File

@@ -2,6 +2,8 @@
Custom provides the possibility of load an image to be presented as a node. Custom provides the possibility of load an image to be presented as a node.
""" """


from typing import Any, Dict, Optional, override

from diagrams import Node from diagrams import Node




@@ -12,9 +14,11 @@ class Custom(Node):


fontcolor = "#ffffff" fontcolor = "#ffffff"


def _load_icon(self):
@override
def _load_icon(self) -> Optional[str]:
return self._icon return self._icon


def __init__(self, label, icon_path, *args, **kwargs):
self._icon = icon_path
super().__init__(label, *args, **kwargs)
@override
def __init__(self, label: str, icon_path: Optional[str], *, nodeid: Optional[str] = None, **attrs: Dict[Any, Any]):
self._icon: Optional[str] = icon_path
super().__init__(label, nodeid=nodeid, **attrs)

+ 54
- 36
poetry.lock View File

@@ -1,10 +1,9 @@
# This file is automatically @generated by Poetry and should not be changed by hand.
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.


[[package]] [[package]]
name = "astroid" name = "astroid"
version = "2.9.0" version = "2.9.0"
description = "An abstract syntax tree for Python with inference support." description = "An abstract syntax tree for Python with inference support."
category = "dev"
optional = false optional = false
python-versions = "~=3.6" python-versions = "~=3.6"
files = [ files = [
@@ -23,7 +22,6 @@ wrapt = ">=1.11,<1.14"
name = "black" name = "black"
version = "22.12.0" version = "22.12.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -60,7 +58,6 @@ uvloop = ["uvloop (>=0.15.2)"]
name = "click" name = "click"
version = "8.1.3" version = "8.1.3"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -76,7 +73,6 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
name = "colorama" name = "colorama"
version = "0.4.3" version = "0.4.3"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [ files = [
@@ -88,7 +84,6 @@ files = [
name = "exceptiongroup" name = "exceptiongroup"
version = "1.1.0" version = "1.1.0"
description = "Backport of PEP 654 (exception groups)" description = "Backport of PEP 654 (exception groups)"
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -103,7 +98,6 @@ test = ["pytest (>=6)"]
name = "graphviz" name = "graphviz"
version = "0.20.1" version = "0.20.1"
description = "Simple Python interface for Graphviz" description = "Simple Python interface for Graphviz"
category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -120,7 +114,6 @@ test = ["coverage", "mock (>=4)", "pytest (>=7)", "pytest-cov", "pytest-mock (>=
name = "importlib-metadata" name = "importlib-metadata"
version = "1.5.0" version = "1.5.0"
description = "Read metadata from Python packages" description = "Read metadata from Python packages"
category = "dev"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
files = [ files = [
@@ -139,7 +132,6 @@ testing = ["importlib-resources", "packaging"]
name = "iniconfig" name = "iniconfig"
version = "1.1.1" version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing" description = "iniconfig: brain-dead simple config-ini parsing"
category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
@@ -151,7 +143,6 @@ files = [
name = "isort" name = "isort"
version = "4.3.21" version = "4.3.21"
description = "A Python utility / library to sort Python imports." description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [ files = [
@@ -169,7 +160,6 @@ xdg-home = ["appdirs (>=1.4.0)"]
name = "jinja2" name = "jinja2"
version = "3.1.2" version = "3.1.2"
description = "A very fast and expressive template engine." description = "A very fast and expressive template engine."
category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -187,7 +177,6 @@ i18n = ["Babel (>=2.7)"]
name = "lazy-object-proxy" name = "lazy-object-proxy"
version = "1.4.3" version = "1.4.3"
description = "A fast and thorough lazy object proxy." description = "A fast and thorough lazy object proxy."
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [ files = [
@@ -218,7 +207,6 @@ files = [
name = "markupsafe" name = "markupsafe"
version = "2.0.1" version = "2.0.1"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
@@ -297,7 +285,6 @@ files = [
name = "mccabe" name = "mccabe"
version = "0.6.1" version = "0.6.1"
description = "McCabe checker, plugin for flake8" description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
@@ -306,22 +293,67 @@ files = [
] ]


[[package]] [[package]]
name = "mypy"
version = "1.4.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "mypy-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8"},
{file = "mypy-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878"},
{file = "mypy-1.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd"},
{file = "mypy-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8c4d8e89aa7de683e2056a581ce63c46a0c41e31bd2b6d34144e2c80f5ea53dc"},
{file = "mypy-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:bfdca17c36ae01a21274a3c387a63aa1aafe72bff976522886869ef131b937f1"},
{file = "mypy-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7549fbf655e5825d787bbc9ecf6028731973f78088fbca3a1f4145c39ef09462"},
{file = "mypy-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98324ec3ecf12296e6422939e54763faedbfcc502ea4a4c38502082711867258"},
{file = "mypy-1.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2"},
{file = "mypy-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7"},
{file = "mypy-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01"},
{file = "mypy-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b"},
{file = "mypy-1.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b"},
{file = "mypy-1.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7"},
{file = "mypy-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9"},
{file = "mypy-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042"},
{file = "mypy-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3"},
{file = "mypy-1.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6"},
{file = "mypy-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f"},
{file = "mypy-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc"},
{file = "mypy-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828"},
{file = "mypy-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3"},
{file = "mypy-1.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816"},
{file = "mypy-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5703097c4936bbb9e9bce41478c8d08edd2865e177dc4c52be759f81ee4dd26c"},
{file = "mypy-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e02d700ec8d9b1859790c0475df4e4092c7bf3272a4fd2c9f33d87fac4427b8f"},
{file = "mypy-1.4.1-py3-none-any.whl", hash = "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4"},
{file = "mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b"},
]

[package.dependencies]
mypy-extensions = ">=1.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""}
typing-extensions = ">=4.1.0"

[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
python2 = ["typed-ast (>=1.4.0,<2)"]
reports = ["lxml"]

[[package]]
name = "mypy-extensions" name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false optional = false
python-versions = "*"
python-versions = ">=3.5"
files = [ files = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
] ]


[[package]] [[package]]
name = "packaging" name = "packaging"
version = "20.8" version = "20.8"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [ files = [
@@ -336,7 +368,6 @@ pyparsing = ">=2.0.2"
name = "pathspec" name = "pathspec"
version = "0.10.1" version = "0.10.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -348,7 +379,6 @@ files = [
name = "platformdirs" name = "platformdirs"
version = "2.4.0" version = "2.4.0"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
@@ -364,7 +394,6 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock
name = "pluggy" name = "pluggy"
version = "0.13.1" version = "0.13.1"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [ files = [
@@ -382,7 +411,6 @@ dev = ["pre-commit", "tox"]
name = "pylint" name = "pylint"
version = "2.12.0" version = "2.12.0"
description = "python code static checker" description = "python code static checker"
category = "dev"
optional = false optional = false
python-versions = "~=3.6" python-versions = "~=3.6"
files = [ files = [
@@ -403,7 +431,6 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""
name = "pyparsing" name = "pyparsing"
version = "2.4.7" version = "2.4.7"
description = "Python parsing module" description = "Python parsing module"
category = "dev"
optional = false optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [ files = [
@@ -415,7 +442,6 @@ files = [
name = "pytest" name = "pytest"
version = "7.3.0" version = "7.3.0"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -439,7 +465,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no
name = "rope" name = "rope"
version = "0.14.0" version = "0.14.0"
description = "a python refactoring library..." description = "a python refactoring library..."
category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
@@ -452,7 +477,6 @@ files = [
name = "setuptools" name = "setuptools"
version = "59.6.0" version = "59.6.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages" description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
@@ -468,7 +492,6 @@ testing = ["flake8-2020", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock"
name = "toml" name = "toml"
version = "0.10.0" version = "0.10.0"
description = "Python Library for Tom's Obvious, Minimal Language" description = "Python Library for Tom's Obvious, Minimal Language"
category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
@@ -480,7 +503,6 @@ files = [
name = "tomli" name = "tomli"
version = "1.2.3" version = "1.2.3"
description = "A lil' TOML parser" description = "A lil' TOML parser"
category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
@@ -492,7 +514,6 @@ files = [
name = "typed-ast" name = "typed-ast"
version = "1.5.4" version = "1.5.4"
description = "a fork of Python 2 and 3 ast modules with type comment support" description = "a fork of Python 2 and 3 ast modules with type comment support"
category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
@@ -526,7 +547,6 @@ files = [
name = "typing-extensions" name = "typing-extensions"
version = "4.1.1" version = "4.1.1"
description = "Backported and Experimental Type Hints for Python 3.6+" description = "Backported and Experimental Type Hints for Python 3.6+"
category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
@@ -538,7 +558,6 @@ files = [
name = "wrapt" name = "wrapt"
version = "1.11.2" version = "1.11.2"
description = "Module for decorators, wrappers and monkey patching." description = "Module for decorators, wrappers and monkey patching."
category = "dev"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
@@ -549,7 +568,6 @@ files = [
name = "zipp" name = "zipp"
version = "3.1.0" version = "3.1.0"
description = "Backport of pathlib-compatible object wrapper for zip files" description = "Backport of pathlib-compatible object wrapper for zip files"
category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
@@ -564,4 +582,4 @@ testing = ["func-timeout", "jaraco.itertools"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "5c2b82d1c8a6a283f63558fc693271f42fdcaeab73616713eb9b38b0b59787fc"
content-hash = "03625aefdb3d0562b74d70e00eda5ddb3a477a433adc9aa01b351e3cef897403"

+ 9
- 0
pyproject.toml View File

@@ -21,6 +21,15 @@ pylint = "^2.7"
rope = "^0.14.0" rope = "^0.14.0"
isort = "^4.3" isort = "^4.3"
black = "^22.12.0" black = "^22.12.0"
mypy = ">=1.4,<1.8"


[tool.black] [tool.black]
line-length = 120 line-length = 120

[tool.mypy]
files = "diagrams, tests"
mypy_path = "src"
namespace_packages = true
explicit_package_bases = true
show_error_codes = true
strict = true

+ 13
- 14
tests/test_c4.py View File

@@ -3,47 +3,46 @@ import random
import string import string
import unittest import unittest


from diagrams import Diagram
from diagrams import setcluster, setdiagram
from diagrams import setcluster, setdiagram, Diagram
from diagrams.c4 import Person, Container, Database, System, SystemBoundary, Relationship from diagrams.c4 import Person, Container, Database, System, SystemBoundary, Relationship




class C4Test(unittest.TestCase): class C4Test(unittest.TestCase):
def setUp(self):
self.name = "diagram-" + "".join([random.choice(string.hexdigits) for n in range(7)]).lower()
def setUp(self) -> None:
self.name: str = "diagram-" + "".join([random.choice(string.hexdigits) for n in range(7)]).lower()


def tearDown(self):
def tearDown(self) -> None:
setdiagram(None) setdiagram(None)
setcluster(None) setcluster(None)
try: try:
os.remove(self.name + ".png")
os.remove(f"{self.name}.png")
except FileNotFoundError: except FileNotFoundError:
pass pass


def test_nodes(self):
def test_nodes(self) -> None:
with Diagram(name=self.name, show=False): with Diagram(name=self.name, show=False):
person = Person("person", "A person.") person = Person("person", "A person.")
container = Container("container", "Java application", "The application.") container = Container("container", "Java application", "The application.")
database = Database("database", "Oracle database", "Stores information.") database = Database("database", "Oracle database", "Stores information.")


def test_external_nodes(self):
def test_external_nodes(self) -> None:
with Diagram(name=self.name, show=False): with Diagram(name=self.name, show=False):
external_person = Person("person", external=True) external_person = Person("person", external=True)
external_system = System("external", external=True) external_system = System("external", external=True)


def test_systems(self):
def test_systems(self) -> None:
with Diagram(name=self.name, show=False): with Diagram(name=self.name, show=False):
system = System("system", "The internal system.") system = System("system", "The internal system.")
system_without_description = System("unknown") system_without_description = System("unknown")


def test_edges(self):
def test_edges(self) -> None:
with Diagram(name=self.name, show=False): with Diagram(name=self.name, show=False):
c1 = Container("container1") c1 = Container("container1")
c2 = Container("container2") c2 = Container("container2")


c1 >> c2 c1 >> c2


def test_edges_with_labels(self):
def test_edges_with_labels(self) -> None:
with Diagram(name=self.name, show=False): with Diagram(name=self.name, show=False):
c1 = Container("container1") c1 = Container("container1")
c2 = Container("container2") c2 = Container("container2")
@@ -51,14 +50,14 @@ class C4Test(unittest.TestCase):
c1 >> Relationship("depends on") >> c2 c1 >> Relationship("depends on") >> c2
c1 << Relationship("is depended on by") << c2 c1 << Relationship("is depended on by") << c2


def test_edge_without_constraint(self):
def test_edge_without_constraint(self) -> None:
with Diagram(name=self.name, show=False): with Diagram(name=self.name, show=False):
s1 = System("system 1") s1 = System("system 1")
s2 = System("system 2") s2 = System("system 2")


s1 >> Relationship(constraint="False") >> s2
s1 >> Relationship(constraint="False") >> s2 # type: ignore[arg-type]


def test_cluster(self):
def test_cluster(self) -> None:
with Diagram(name=self.name, show=False): with Diagram(name=self.name, show=False):
with SystemBoundary("System"): with SystemBoundary("System"):
Container("container", "type", "description") Container("container", "type", "description")

+ 44
- 46
tests/test_diagram.py View File

@@ -3,15 +3,14 @@ import shutil
import unittest import unittest
import pathlib import pathlib


from diagrams import Cluster, Diagram, Edge, Node
from diagrams import getcluster, getdiagram, setcluster, setdiagram
from diagrams import Cluster, Diagram, Edge, Node, getcluster, getdiagram, setcluster, setdiagram




class DiagramTest(unittest.TestCase): class DiagramTest(unittest.TestCase):
def setUp(self):
self.name = "diagram_test"
def setUp(self) -> None:
self.name: str = "diagram_test"


def tearDown(self):
def tearDown(self) -> None:
setdiagram(None) setdiagram(None)
setcluster(None) setcluster(None)
# Only some tests generate the image file. # Only some tests generate the image file.
@@ -20,11 +19,11 @@ class DiagramTest(unittest.TestCase):
except OSError: except OSError:
# Consider it file # Consider it file
try: try:
os.remove(self.name + ".png")
os.remove(f"{self.name}.png")
except FileNotFoundError: except FileNotFoundError:
pass pass


def test_validate_direction(self):
def test_validate_direction(self) -> None:
# Normal directions. # Normal directions.
for dir in ("TB", "BT", "LR", "RL", "tb"): for dir in ("TB", "BT", "LR", "RL", "tb"):
Diagram(direction=dir) Diagram(direction=dir)
@@ -34,7 +33,7 @@ class DiagramTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
Diagram(direction=dir) Diagram(direction=dir)


def test_validate_curvestyle(self):
def test_validate_curvestyle(self) -> None:
# Normal directions. # Normal directions.
for cvs in ("ortho", "curved", "CURVED"): for cvs in ("ortho", "curved", "CURVED"):
Diagram(curvestyle=cvs) Diagram(curvestyle=cvs)
@@ -44,7 +43,7 @@ class DiagramTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
Diagram(curvestyle=cvs) Diagram(curvestyle=cvs)


def test_validate_outformat(self):
def test_validate_outformat(self) -> None:
# Normal output formats. # Normal output formats.
for fmt in ("png", "jpg", "svg", "pdf", "PNG", "dot"): for fmt in ("png", "jpg", "svg", "pdf", "PNG", "dot"):
Diagram(outformat=fmt) Diagram(outformat=fmt)
@@ -54,18 +53,18 @@ class DiagramTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
Diagram(outformat=fmt) Diagram(outformat=fmt)


def test_with_global_context(self):
def test_with_global_context(self) -> None:
self.assertIsNone(getdiagram()) self.assertIsNone(getdiagram())
with Diagram(name=os.path.join(self.name, "with_global_context"), show=False): with Diagram(name=os.path.join(self.name, "with_global_context"), show=False):
self.assertIsNotNone(getdiagram()) self.assertIsNotNone(getdiagram())
self.assertIsNone(getdiagram()) self.assertIsNone(getdiagram())


def test_node_not_in_diagram(self):
def test_node_not_in_diagram(self) -> None:
# Node must be belong to a diagrams. # Node must be belong to a diagrams.
with self.assertRaises(EnvironmentError): with self.assertRaises(EnvironmentError):
Node("node") Node("node")


def test_node_to_node(self):
def test_node_to_node(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_node"), show=False): with Diagram(name=os.path.join(self.name, "node_to_node"), show=False):
node1 = Node("node1") node1 = Node("node1")
node2 = Node("node2") node2 = Node("node2")
@@ -73,7 +72,7 @@ class DiagramTest(unittest.TestCase):
self.assertEqual(node1 >> node2, node2) self.assertEqual(node1 >> node2, node2)
self.assertEqual(node1 << node2, node2) self.assertEqual(node1 << node2, node2)


def test_node_to_nodes(self):
def test_node_to_nodes(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_nodes"), show=False): with Diagram(name=os.path.join(self.name, "node_to_nodes"), show=False):
node1 = Node("node1") node1 = Node("node1")
nodes = [Node("node2"), Node("node3")] nodes = [Node("node2"), Node("node3")]
@@ -81,7 +80,7 @@ class DiagramTest(unittest.TestCase):
self.assertEqual(node1 >> nodes, nodes) self.assertEqual(node1 >> nodes, nodes)
self.assertEqual(node1 << nodes, nodes) self.assertEqual(node1 << nodes, nodes)


def test_nodes_to_node(self):
def test_nodes_to_node(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False):
node1 = Node("node1") node1 = Node("node1")
nodes = [Node("node2"), Node("node3")] nodes = [Node("node2"), Node("node3")]
@@ -89,32 +88,31 @@ class DiagramTest(unittest.TestCase):
self.assertEqual(nodes >> node1, node1) self.assertEqual(nodes >> node1, node1)
self.assertEqual(nodes << node1, node1) self.assertEqual(nodes << node1, node1)


def test_default_filename(self):
def test_default_filename(self) -> None:
self.name = "example_1" self.name = "example_1"
with Diagram(name="Example 1", show=False): with Diagram(name="Example 1", show=False):
Node("node1") Node("node1")
self.assertTrue(os.path.exists(f"{self.name}.png")) self.assertTrue(os.path.exists(f"{self.name}.png"))


def test_custom_filename(self):
def test_custom_filename(self) -> None:
self.name = "my_custom_name" self.name = "my_custom_name"
with Diagram(name="Example 1", filename=self.name, show=False): with Diagram(name="Example 1", filename=self.name, show=False):
Node("node1") Node("node1")
self.assertTrue(os.path.exists(f"{self.name}.png")) self.assertTrue(os.path.exists(f"{self.name}.png"))


def test_empty_name(self):
def test_empty_name(self) -> None:
"""Check that providing an empty name don't crash, but save in a diagrams_image.xxx file.""" """Check that providing an empty name don't crash, but save in a diagrams_image.xxx file."""
self.name = 'diagrams_image' self.name = 'diagrams_image'
with Diagram(show=False): with Diagram(show=False):
Node("node1") Node("node1")
self.assertTrue(os.path.exists(f"{self.name}.png")) self.assertTrue(os.path.exists(f"{self.name}.png"))
def test_autolabel(self):
def test_autolabel(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False):
node1 = Node("node1") node1 = Node("node1")
self.assertTrue(node1.label,"Node\nnode1") self.assertTrue(node1.label,"Node\nnode1")



def test_outformat_list(self):
def test_outformat_list(self) -> None:
"""Check that outformat render all the files from the list.""" """Check that outformat render all the files from the list."""
self.name = 'diagrams_image' self.name = 'diagrams_image'
with Diagram(show=False, outformat=["dot", "png"]): with Diagram(show=False, outformat=["dot", "png"]):
@@ -128,10 +126,10 @@ class DiagramTest(unittest.TestCase):




class ClusterTest(unittest.TestCase): class ClusterTest(unittest.TestCase):
def setUp(self):
self.name = "cluster_test"
def setUp(self) -> None:
self.name: str = "cluster_test"


def tearDown(self):
def tearDown(self) -> None:
setdiagram(None) setdiagram(None)
setcluster(None) setcluster(None)
# Only some tests generate the image file. # Only some tests generate the image file.
@@ -140,7 +138,7 @@ class ClusterTest(unittest.TestCase):
except OSError: except OSError:
pass pass


def test_validate_direction(self):
def test_validate_direction(self) -> None:
# Normal directions. # Normal directions.
for dir in ("TB", "BT", "LR", "RL"): for dir in ("TB", "BT", "LR", "RL"):
with Diagram(name=os.path.join(self.name, "validate_direction"), show=False): with Diagram(name=os.path.join(self.name, "validate_direction"), show=False):
@@ -152,14 +150,14 @@ class ClusterTest(unittest.TestCase):
with Diagram(name=os.path.join(self.name, "validate_direction"), show=False): with Diagram(name=os.path.join(self.name, "validate_direction"), show=False):
Cluster(direction=dir) Cluster(direction=dir)


def test_with_global_context(self):
def test_with_global_context(self) -> None:
with Diagram(name=os.path.join(self.name, "with_global_context"), show=False): with Diagram(name=os.path.join(self.name, "with_global_context"), show=False):
self.assertIsNone(getcluster()) self.assertIsNone(getcluster())
with Cluster(): with Cluster():
self.assertIsNotNone(getcluster()) self.assertIsNotNone(getcluster())
self.assertIsNone(getcluster()) self.assertIsNone(getcluster())


def test_with_nested_cluster(self):
def test_with_nested_cluster(self) -> None:
with Diagram(name=os.path.join(self.name, "with_nested_cluster"), show=False): with Diagram(name=os.path.join(self.name, "with_nested_cluster"), show=False):
self.assertIsNone(getcluster()) self.assertIsNone(getcluster())
with Cluster() as c1: with Cluster() as c1:
@@ -169,12 +167,12 @@ class ClusterTest(unittest.TestCase):
self.assertEqual(c1, getcluster()) self.assertEqual(c1, getcluster())
self.assertIsNone(getcluster()) self.assertIsNone(getcluster())


def test_node_not_in_diagram(self):
def test_node_not_in_diagram(self) -> None:
# Node must be belong to a diagrams. # Node must be belong to a diagrams.
with self.assertRaises(EnvironmentError): with self.assertRaises(EnvironmentError):
Node("node") Node("node")


def test_node_to_node(self):
def test_node_to_node(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_node"), show=False): with Diagram(name=os.path.join(self.name, "node_to_node"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
@@ -183,7 +181,7 @@ class ClusterTest(unittest.TestCase):
self.assertEqual(node1 >> node2, node2) self.assertEqual(node1 >> node2, node2)
self.assertEqual(node1 << node2, node2) self.assertEqual(node1 << node2, node2)


def test_node_to_nodes(self):
def test_node_to_nodes(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_nodes"), show=False): with Diagram(name=os.path.join(self.name, "node_to_nodes"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
@@ -192,7 +190,7 @@ class ClusterTest(unittest.TestCase):
self.assertEqual(node1 >> nodes, nodes) self.assertEqual(node1 >> nodes, nodes)
self.assertEqual(node1 << nodes, nodes) self.assertEqual(node1 << nodes, nodes)


def test_nodes_to_node(self):
def test_nodes_to_node(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
@@ -203,10 +201,10 @@ class ClusterTest(unittest.TestCase):




class EdgeTest(unittest.TestCase): class EdgeTest(unittest.TestCase):
def setUp(self):
self.name = "edge_test"
def setUp(self) -> None:
self.name: str = "edge_test"


def tearDown(self):
def tearDown(self) -> None:
setdiagram(None) setdiagram(None)
setcluster(None) setcluster(None)
# Only some tests generate the image file. # Only some tests generate the image file.
@@ -215,34 +213,34 @@ class EdgeTest(unittest.TestCase):
except OSError: except OSError:
pass pass


def test_node_to_node(self):
def test_node_to_node(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_node"), show=False): with Diagram(name=os.path.join(self.name, "node_to_node"), show=False):
node1 = Node("node1") node1 = Node("node1")
node2 = Node("node2") node2 = Node("node2")
self.assertEqual(node1 - Edge(color="red") - node2, node2) self.assertEqual(node1 - Edge(color="red") - node2, node2)


def test_node_to_nodes(self):
def test_node_to_nodes(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_nodes"), show=False): with Diagram(name=os.path.join(self.name, "node_to_nodes"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
nodes = [Node("node2"), Node("node3")] nodes = [Node("node2"), Node("node3")]
self.assertEqual(node1 - Edge(color="red") - nodes, nodes)
self.assertEqual(node1 - Edge(color="red") - nodes, nodes) # type: ignore[operator]


def test_nodes_to_node(self):
def test_nodes_to_node(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
nodes = [Node("node2"), Node("node3")] nodes = [Node("node2"), Node("node3")]
self.assertEqual(nodes - Edge(color="red") - node1, node1) self.assertEqual(nodes - Edge(color="red") - node1, node1)


def test_nodes_to_node_with_additional_attributes(self):
def test_nodes_to_node_with_additional_attributes(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node_with_additional_attributes"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node_with_additional_attributes"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
nodes = [Node("node2"), Node("node3")] nodes = [Node("node2"), Node("node3")]
self.assertEqual(nodes - Edge(color="red") - Edge(color="green") - node1, node1) self.assertEqual(nodes - Edge(color="red") - Edge(color="green") - node1, node1)


def test_node_to_node_with_attributes(self):
def test_node_to_node_with_attributes(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_node_with_attributes"), show=False): with Diagram(name=os.path.join(self.name, "node_to_node_with_attributes"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
@@ -251,7 +249,7 @@ class EdgeTest(unittest.TestCase):
self.assertEqual(node1 >> Edge(color="green", label="1.2") >> node2, node2) self.assertEqual(node1 >> Edge(color="green", label="1.2") >> node2, node2)
self.assertEqual(node1 << Edge(color="blue", label="1.3") >> node2, node2) self.assertEqual(node1 << Edge(color="blue", label="1.3") >> node2, node2)


def test_node_to_node_with_additional_attributes(self):
def test_node_to_node_with_additional_attributes(self) -> None:
with Diagram(name=os.path.join(self.name, "node_to_node_with_additional_attributes"), show=False): with Diagram(name=os.path.join(self.name, "node_to_node_with_additional_attributes"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
@@ -260,7 +258,7 @@ class EdgeTest(unittest.TestCase):
self.assertEqual(node1 >> Edge(color="green", label="2.2") >> Edge(color="red") >> node2, node2) self.assertEqual(node1 >> Edge(color="green", label="2.2") >> Edge(color="red") >> node2, node2)
self.assertEqual(node1 << Edge(color="blue", label="2.3") >> Edge(color="black") >> node2, node2) self.assertEqual(node1 << Edge(color="blue", label="2.3") >> Edge(color="black") >> node2, node2)


def test_nodes_to_node_with_attributes_loop(self):
def test_nodes_to_node_with_attributes_loop(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_loop"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_loop"), show=False):
with Cluster(): with Cluster():
node = Node("node") node = Node("node")
@@ -269,21 +267,21 @@ class EdgeTest(unittest.TestCase):
self.assertEqual(node >> Edge(color="blue", label="3.3") << node, node) self.assertEqual(node >> Edge(color="blue", label="3.3") << node, node)
self.assertEqual(node << Edge(color="pink", label="3.4") >> node, node) self.assertEqual(node << Edge(color="pink", label="3.4") >> node, node)


def test_nodes_to_node_with_attributes_bothdirectional(self):
def test_nodes_to_node_with_attributes_bothdirectional(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_bothdirectional"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_bothdirectional"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
nodes = [Node("node2"), Node("node3")] nodes = [Node("node2"), Node("node3")]
self.assertEqual(nodes << Edge(color="green", label="4") >> node1, node1) self.assertEqual(nodes << Edge(color="green", label="4") >> node1, node1)


def test_nodes_to_node_with_attributes_bidirectional(self):
def test_nodes_to_node_with_attributes_bidirectional(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_bidirectional"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_bidirectional"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
nodes = [Node("node2"), Node("node3")] nodes = [Node("node2"), Node("node3")]
self.assertEqual(nodes << Edge(color="blue", label="5") >> node1, node1) self.assertEqual(nodes << Edge(color="blue", label="5") >> node1, node1)


def test_nodes_to_node_with_attributes_onedirectional(self):
def test_nodes_to_node_with_attributes_onedirectional(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_onedirectional"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node_with_attributes_onedirectional"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
@@ -291,7 +289,7 @@ class EdgeTest(unittest.TestCase):
self.assertEqual(nodes >> Edge(color="red", label="6.1") >> node1, node1) self.assertEqual(nodes >> Edge(color="red", label="6.1") >> node1, node1)
self.assertEqual(nodes << Edge(color="green", label="6.2") << node1, node1) self.assertEqual(nodes << Edge(color="green", label="6.2") << node1, node1)


def test_nodes_to_node_with_additional_attributes_directional(self):
def test_nodes_to_node_with_additional_attributes_directional(self) -> None:
with Diagram(name=os.path.join(self.name, "nodes_to_node_with_additional_attributes_directional"), show=False): with Diagram(name=os.path.join(self.name, "nodes_to_node_with_additional_attributes_directional"), show=False):
with Cluster(): with Cluster():
node1 = Node("node1") node1 = Node("node1")
@@ -305,7 +303,7 @@ class EdgeTest(unittest.TestCase):




class ResourcesTest(unittest.TestCase): class ResourcesTest(unittest.TestCase):
def test_folder_depth(self):
def test_folder_depth(self) -> None:
""" """
The code currently only handles resource folders up to a dir depth of 2 The code currently only handles resource folders up to a dir depth of 2
i.e. resources/<provider>/<type>/<image>, so check that this depth isn't i.e. resources/<provider>/<type>/<image>, so check that this depth isn't


Loading…
Cancel
Save