Skip to content

Commit

Permalink
feat: Basic support for C4 model primitives. (#508)
Browse files Browse the repository at this point in the history
* Basic support for C4 model primitives.

* Use the "rect" shape for nodes

With the record shape we used before, graphviz would trip over
edges that set constraint=False.

* Adopt C4 terminology: Rename Dependency -> Relationship

* Adopt C4 terminology: Rename type -> technology

* Extract a shared C4Node

This makes the code more DRY, but also allows to add company-
specific extensions more easily. One need we have is to slightly
adapt the terminology. At Spotify, we happen to call `Container`
a `Component` for example. This is now easier to implement on top
of the shared `C4Node`.

* Add "C4" shield to the README

* Document how to produce a C4 diagram
  • Loading branch information
mbruggmann authored Sep 5, 2022
1 parent e8eb3d8 commit 90dd239
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions diagrams/c4/__init__.py
Original file line number Diff line number Diff line change
@@ -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'<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 ""
text = f'<br/><font point-size="10">{_format_description(description)}</font>' 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 "<br/>".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 = "<br/>".join(lines)
return f'<<font point-size="10">{text}</font>>'


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)
77 changes: 77 additions & 0 deletions docs/nodes/c4.md
Original file line number Diff line number Diff line change
@@ -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)
64 changes: 64 additions & 0 deletions tests/test_c4.py
Original file line number Diff line number Diff line change
@@ -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")
Binary file added website/static/img/c4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 90dd239

Please sign in to comment.