diff --git a/README.md b/README.md index 0633255..f958ab2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Diagrams lets you draw the cloud system architecture **in Python code**. It was ![generic provider](https://img.shields.io/badge/Generic-orange?color=5f87bf) ![programming provider](https://img.shields.io/badge/Programming-orange?color=5f87bf) ![saas provider](https://img.shields.io/badge/SaaS-orange?color=5f87bf) +![c4 provider](https://img.shields.io/badge/C4-orange?color=5f87bf) ## Getting Started diff --git a/diagrams/c4/__init__.py b/diagrams/c4/__init__.py new file mode 100644 index 0000000..40577c8 --- /dev/null +++ b/diagrams/c4/__init__.py @@ -0,0 +1,97 @@ +""" +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, key, description): + """Create a graphviz label string for a C4 node""" + title = f'{html.escape(name)}
' + subtitle = f'[{html.escape(key)}]
' if key 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 C4Node(name, technology="", description="", type="Container", **kwargs): + key = f"{type}: {technology}" if technology else type + node_attributes = { + "label": _format_node_label(name, key, description), + "labelloc": "c", + "shape": "rect", + "width": "2.6", + "height": "1.6", + "fixedsize": "true", + "style": "filled", + "fillcolor": "dodgerblue3", + "fontcolor": "white", + } + # collapse 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 Container(name, technology="", description="", **kwargs): + return C4Node(name, technology=technology, description=description, type="Container") + + +def Database(name, technology="", description="", **kwargs): + return C4Node(name, technology=technology, description=description, type="Database", shape="cylinder", labelloc="b") + + +def System(name, description="", external=False, **kwargs): + type = "External System" if external else "System" + fillcolor = "gray60" if external else "dodgerblue4" + return C4Node(name, description=description, type=type, fillcolor=fillcolor) + + +def Person(name, description="", external=False, **kwargs): + type = "External Person" if external else "Person" + fillcolor = "gray60" if external else "dodgerblue4" + style = "rounded,filled" + return C4Node(name, description=description, type=type, fillcolor=fillcolor, style=style) + + +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 Relationship(label="", **kwargs): + edge_attribtues = {"style": "dashed", "color": "gray60"} + if label: + edge_attribtues.update({"label": _format_edge_label(label)}) + edge_attribtues.update(kwargs) + return Edge(**edge_attribtues) diff --git a/docs/nodes/c4.md b/docs/nodes/c4.md new file mode 100644 index 0000000..9c21c2c --- /dev/null +++ b/docs/nodes/c4.md @@ -0,0 +1,77 @@ +--- +id: c4 +title: C4 +--- + +## C4 Diagrams + +[C4](https://c4model.com/) is a standardized model to visualize software architecture. +You can generate C4 diagrams by using the node and edge classes from the `diagrams.c4` package: + +```python +from diagrams import Diagram +from diagrams.c4 import Person, Container, Database, System, SystemBoundary, Relationship + +graph_attr = { + "splines": "spline", +} + +with Diagram("Container diagram for Internet Banking System", direction="TB", graph_attr=graph_attr): + customer = Person( + name="Personal Banking Customer", description="A customer of the bank, with personal bank accounts." + ) + + with SystemBoundary("Internet Banking System"): + webapp = Container( + name="Web Application", + technology="Java and Spring MVC", + description="Delivers the static content and the Internet banking single page application.", + ) + + spa = Container( + name="Single-Page Application", + technology="Javascript and Angular", + description="Provides all of the Internet banking functionality to customers via their web browser.", + ) + + mobileapp = Container( + name="Mobile App", + technology="Xamarin", + description="Provides a limited subset of the Internet banking functionality to customers via their mobile device.", + ) + + api = Container( + name="API Application", + technology="Java and Spring MVC", + description="Provides Internet banking functionality via a JSON/HTTPS API.", + ) + + database = Database( + name="Database", + technology="Oracle Database Schema", + description="Stores user registration information, hashed authentication credentials, access logs, etc.", + ) + + email = System(name="E-mail System", description="The internal Microsoft Exchange e-mail system.", external=True) + + mainframe = System( + name="Mainframe Banking System", + description="Stores all of the core banking information about customers, accounts, transactions, etc.", + external=True, + ) + + customer >> Relationship("Visits bigbank.com/ib using [HTTPS]") >> webapp + customer >> Relationship("Views account balances, and makes payments using") >> [spa, mobileapp] + webapp >> Relationship("Delivers to the customer's web browser") >> spa + spa >> Relationship("Make API calls to [JSON/HTTPS]") >> api + mobileapp >> Relationship("Make API calls to [JSON/HTTPS]") >> api + + api >> Relationship("reads from and writes to") >> database + api >> Relationship("Sends email using [SMTP]") >> email + api >> Relationship("Makes API calls to [XML/HTTPS]") >> mainframe + customer << Relationship("Sends e-mails to") << email +``` + +It will produce the following diagram: + +![c4](/img/c4.png) diff --git a/tests/test_c4.py b/tests/test_c4.py new file mode 100644 index 0000000..25c8545 --- /dev/null +++ b/tests/test_c4.py @@ -0,0 +1,64 @@ +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, Relationship + + +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.") + + def test_external_nodes(self): + with Diagram(name=self.name, show=False): + external_person = Person("person", external=True) + external_system = System("external", external=True) + + def test_systems(self): + with Diagram(name=self.name, show=False): + system = System("system", "The internal system.") + system_without_description = System("unknown") + + def test_edges(self): + with Diagram(name=self.name, show=False): + c1 = Container("container1") + c2 = Container("container2") + + c1 >> c2 + + def test_edges_with_labels(self): + with Diagram(name=self.name, show=False): + c1 = Container("container1") + c2 = Container("container2") + + c1 >> Relationship("depends on") >> c2 + c1 << Relationship("is depended on by") << c2 + + def test_edge_without_constraint(self): + with Diagram(name=self.name, show=False): + s1 = System("system 1") + s2 = System("system 2") + + s1 >> Relationship(constraint="False") >> s2 + + def test_cluster(self): + with Diagram(name=self.name, show=False): + with SystemBoundary("System"): + Container("container", "type", "description") diff --git a/website/static/img/c4.png b/website/static/img/c4.png new file mode 100644 index 0000000..e3ea5cc Binary files /dev/null and b/website/static/img/c4.png differ