From b4729f9194d782cd9f2ff24cdd960a56d679c564 Mon Sep 17 00:00:00 2001 From: Marc Bruggmann Date: Sun, 11 Apr 2021 15:27:06 +0200 Subject: [PATCH] Basic support for C4 model primitives. --- diagrams/c4/__init__.py | 113 ++++++++++++++++++++++++++++++++++++++++ tests/test_c4.py | 50 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 diagrams/c4/__init__.py create mode 100644 tests/test_c4.py diff --git a/diagrams/c4/__init__.py b/diagrams/c4/__init__.py new file mode 100644 index 00000000..62b31717 --- /dev/null +++ b/diagrams/c4/__init__.py @@ -0,0 +1,113 @@ +""" +A set of nodes and edges to visualize software architecture using the C4 model. +""" +import html +import textwrap +from diagrams import Cluster, Node, Edge + + +def _format_node_label(name, type, description): + """Create a graphviz label string for a C4 node""" + title = f'{html.escape(name)}
' + subtitle = f'[{html.escape(type)}]
' if type else "" + text = f'
{_format_description(description)}' if description else "" + return f"<{title}{subtitle}{text}>" + + +def _format_description(description): + """ + Formats the description string so it fits into the C4 nodes. + + It line-breaks the description so it fits onto exactly three lines. If there are more + than three lines, all further lines are discarded and "..." inserted on the last line to + indicate that it was shortened. This will also html-escape the description so it can + safely be included in a HTML label. + """ + wrapper = textwrap.TextWrapper(width=40, max_lines=3) + lines = [html.escape(line) for line in wrapper.wrap(description)] + lines += [""] * (3 - len(lines)) # fill up with empty lines so it is always three + return "
".join(lines) + + +def _format_edge_label(description): + """Create a graphviz label string for a C4 edge""" + wrapper = textwrap.TextWrapper(width=24, max_lines=3) + lines = [html.escape(line) for line in wrapper.wrap(description)] + text = "
".join(lines) + return f'<{text}>' + + +def Container(name, type, description, **kwargs): + node_attributes = { + "label": _format_node_label(name, type, description), + "shape": "record", + "width": "2.6", + "height": "1.6", + "fixedsize": "true", + "style": "filled", + "fillcolor": "dodgerblue3", + "fontcolor": "white", + } + node_attributes.update(kwargs) + return Node(**node_attributes) + + +def Database(name, type, description, **kwargs): + node_attributes = { + "label": _format_node_label(name, type, description), + "shape": "cylinder", + "width": "2.6", + "height": "1.6", + "fixedsize": "true", + "style": "filled", + "fillcolor": "dodgerblue3", + "fontcolor": "white", + } + node_attributes.update(kwargs) + return Node(**node_attributes) + + +def System(name, description="", external=False, **kwargs): + type = "External System" if external else "System" + node_attributes = { + "label": _format_node_label(name, type, description), + "shape": "record", + "width": "2.6", + "height": "1.6", + "fixedsize": "true", + "style": "filled", + "fillcolor": "gray60" if external else "dodgerblue4", + "fontcolor": "white", + } + # collapse system boxes to a smaller form if they don't have a description + if not description: + node_attributes.update({"width": "2", "height": "1"}) + node_attributes.update(kwargs) + return Node(**node_attributes) + + +def Person(name, description, **kwargs): + node_attributes = { + "label": _format_node_label(name, "", description), + "shape": "record", + "width": "2.6", + "height": "1.6", + "fixedsize": "true", + "style": "rounded,filled", + "fillcolor": "dodgerblue4", + "fontcolor": "white", + } + node_attributes.update(kwargs) + return Node(**node_attributes) + + +def SystemBoundary(name, **kwargs): + graph_attributes = {"label": html.escape(name), "bgcolor": "white", "margin": "16", "style": "dashed"} + graph_attributes.update(kwargs) + return Cluster(name, graph_attr=graph_attributes) + + +def Dependency(label, **kwargs): + edge_attribtues = {"label": _format_edge_label(label), "style": "dashed", "color": "gray60"} + edge_attribtues.update(kwargs) + return Edge(**edge_attribtues) diff --git a/tests/test_c4.py b/tests/test_c4.py new file mode 100644 index 00000000..fa8d799f --- /dev/null +++ b/tests/test_c4.py @@ -0,0 +1,50 @@ +import os +import random +import string +import unittest + +from diagrams import Diagram +from diagrams import setcluster, setdiagram +from diagrams.c4 import Person, Container, Database, System, SystemBoundary, Dependency + + +class C4Test(unittest.TestCase): + def setUp(self): + self.name = "diagram-" + "".join([random.choice(string.hexdigits) for n in range(7)]) + + def tearDown(self): + setdiagram(None) + setcluster(None) + try: + os.remove(self.name + ".png") + except FileNotFoundError: + pass + + def test_nodes(self): + with Diagram(name=self.name, show=False): + person = Person("person", "A person.") + container = Container("container", "Java application", "The application.") + database = Database("database", "Oracle database", "Stores information.") + + person >> container >> database + + def test_systems(self): + with Diagram(name=self.name, show=False): + system = System("system", "The internal system.") + system_without_description = System("unknown") + external_system = System("external", "The external system.", external=True) + + system >> system_without_description >> external_system + + def test_edges(self): + with Diagram(name=self.name, show=False): + c1 = Container("container1", "type", "description") + c2 = Container("container2", "type", "description") + + c1 >> Dependency("depends on") >> c2 + c1 << Dependency("is dependend on") << c2 + + def test_cluster(self): + with Diagram(name=self.name, show=False): + with SystemBoundary("System"): + Container("container", "type", "description")