-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit d0e40e4
Showing
35 changed files
with
1,067 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
on: [push, pull_request] | ||
|
||
env: | ||
APT_INSTALL: sudo apt install -y --no-install-recommends | ||
|
||
|
||
jobs: | ||
jammy_test: | ||
runs-on: ubuntu-22.04 | ||
|
||
steps: | ||
- uses: actions/checkout@v3 | ||
- run: make install_deps_apt | ||
- run: make install | ||
- run: make test | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
build |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
PROJECT_NAME=hiearch | ||
|
||
APT_INSTALL=sudo apt install -y --no-install-recommends | ||
# DEB_PYTHON_INSTALL_LAYOUT fixes UNKNOWN -> https://github.com/pypa/setuptools/issues/3269 | ||
export DEB_PYTHON_INSTALL_LAYOUT=deb_system | ||
export PYTHONDONTWRITEBYTECODE=1 | ||
|
||
CURRENT_DIR=$(shell pwd) | ||
BUILD_DIR?=${CURRENT_DIR}/build | ||
TEST_NOT= | ||
|
||
.DEFAULT: | ||
@echo "Testing $@..." | ||
${TEST_NOT} hiearch -o ${BUILD_DIR}/$@ ./test/$@/*.yaml | ||
mkdir -p ./build/$@ | ||
find build/$@/ -iname '*.gv' | sort | xargs --no-run-if-empty -I {} sh -c "sort {} | sha256 > ./build/$@/checksum.build" | ||
find test/$@/ -iname '*.gv' | sort | xargs --no-run-if-empty -I {} sh -c "sort {} | sha256 > ./build/$@/checksum.test" | ||
${TEST_NOT} cmp ./build/$@/checksum.build ./build/$@/checksum.test | ||
|
||
venv: builddir | ||
python3 -m venv ${BUILD_DIR}/venv | ||
|
||
venv_test: venv | ||
/bin/sh -c ". ${BUILD_DIR}/venv/bin/activate && ${MAKE} reinstall && ${MAKE} test" | ||
|
||
test: | ||
@${MAKE} 01_basic 02_default_view 03_default_view_split 06_multiscope 07_trivial 08_node_realations || (echo "Failure!" && false) | ||
@${MAKE} TEST_NOT=! 04_node_cycle 05_style_cycle || (echo "Failure!" && false) | ||
@echo "Success!" | ||
|
||
clean: | ||
rm -Rf .pytest_cache | ||
rm -Rf ${BUILD_DIR} | ||
find ./ -name "__pycache__" | xargs rm -Rf | ||
|
||
builddir: | ||
mkdir -p ${BUILD_DIR} | ||
|
||
install: | ||
pip install --no-cache-dir ./ | ||
#${MAKE} clean | ||
|
||
install_edit: | ||
pip install --editable ./ | ||
${MAKE} clean | ||
|
||
install_deps: builddir | ||
pip-compile --verbose --output-file ./${BUILD_DIR}/requirements.txt pyproject.toml | ||
pip3 install -r ./${BUILD_DIR}/requirements.txt | ||
|
||
uninstall: clean | ||
pip uninstall -y ${PROJECT_NAME} | ||
|
||
reinstall: | ||
${MAKE} uninstall | ||
${MAKE} install | ||
|
||
spell: | ||
hunspell README.md | ||
|
||
install_deps_apt: | ||
${APT_INSTALL} graphviz | ||
|
||
|
||
.PHONY: test | ||
|
||
# https://packaging.python.org/en/latest/tutorials/packaging-projects/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
Introduction | ||
============ | ||
|
||
`hiearch` is a CLI utility that generates diagrams from textual descriptions, | ||
a.k.a., "diagrams as code". Unlike many other generators like `graphviz` it is | ||
designed to support hierarchical decomposition and multiple views, in which | ||
sense it is conceptually similar to <https://structurizr.com>. In other words, | ||
each node in a the description is a hierarchy of nodes, that is automatically | ||
expanded, collapsed, or hidden depending on preferences of each particular view | ||
requested by the same description. Currently, `hiearch` uses `graphviz` to | ||
generate individual diagrams corresponding to views, but other backends may be | ||
added in the future. | ||
|
||
The main purpose of `hiearch` is graphical representation of complex systems, | ||
but it is meant to be generic and may find other applications. Why would anyone | ||
need another system diagram generator when there is a multitude of tools that | ||
support UML, C4, etc? I believe that the most important aspects of the system | ||
are its decomposition into components and connections between them, `hiearch` | ||
provides just that, nothing more, so that you can focus on documenting your | ||
system rather than fitting it into a specific design framework. | ||
|
||
|
||
Features | ||
======== | ||
|
||
- `hiearch` does not use a DSL, but rather accepts a set of input `yaml` files | ||
in arbitrary order. The file contents get composed into a single description, | ||
which in turn gets decomposed into views. | ||
|
||
- Description files have flat structure without nesting or inclusion and | ||
contain lists of the following objects: nodes, edges, and views. Hierarchical | ||
relations between nodes are specified using node parameters. | ||
|
||
- Unlike `graphviz`, `hiearch` does not have a concept of a subgraphs: each | ||
node may automatically become a subgraph depending on a view. | ||
|
||
- `hiearch` is also somewhat stricter than `graphviz`: for example, all nodes | ||
must be defined explicitly and cannot be deduced from edge definitions. | ||
|
||
- View is not the same thing as `graphviz` layer | ||
<https://graphviz.org/docs/attrs/layer/>: `graphviz` places all nodes on each | ||
layer and simply makes some of them invisible, which results in awkward | ||
spacing. | ||
|
||
- `hiearch` allows nodes to have multiple parent nodes, which is referenced | ||
here as 'multiscoping'. The idea is, of course, to show parents in different | ||
views, for example, to outline system from logical or hardware point of view. | ||
However, it is possible to visualize all parents in the same diagram, which | ||
may be a bit kinky. | ||
|
||
- `hiearch` supports label templates, which facilitates automatic generation of | ||
URLs, tables, icon inclusions, etc. | ||
|
||
|
||
Examples | ||
======== | ||
|
||
Trivial | ||
------- | ||
|
||
<table> | ||
<tr> | ||
<td> | ||
<pre> | ||
nodes: | ||
- id: ["Test 1", test1] # [label, unique id] | ||
edges: | ||
- link: [test1, test1] # [from node id, to node id] | ||
views: | ||
- id: view1 # unique id | ||
nodes: [test1] # nodes to include | ||
</pre> | ||
</td> | ||
<td align="center"> | ||
<img src="test/07_trivial/view1.svg" alt="view1" /> | ||
</td> | ||
</tr> | ||
</table> | ||
|
||
|
||
|
||
Node relations | ||
-------------- | ||
|
||
<table> | ||
<tr> | ||
<td rowspan="3"> | ||
<pre> | ||
nodes: | ||
- id: ["Test 1", test1] | ||
- id: ["Test 2", test2] | ||
graphviz: # graphviz attributes that passed as is | ||
fillcolor: aqua | ||
style: filled | ||
- id: ["Test 3", test3] | ||
scope: test1 # test3 is contained in test1 | ||
style: test2 # test3 inherits style of test2 | ||
edges: | ||
- link: [test3, test3] | ||
views: | ||
- id: view1 | ||
nodes: [test2, test3] | ||
- id: view2 | ||
nodes: [test1, test3] | ||
- id: view3 | ||
nodes: [test1, test2] | ||
</pre> | ||
</td> | ||
<td align="center"> | ||
<img src="test/08_node_realations/view1.svg" alt="view1" /> | ||
</td> | ||
</tr> | ||
<tr> | ||
<td align="center"> | ||
<img src="test/08_node_realations/view2.svg" alt="view2" /> | ||
</td> | ||
</tr> | ||
<tr> | ||
<td align="center"> | ||
<img src="test/08_node_realations/view3.svg" alt="view3" /> | ||
</td> | ||
</tr> | ||
</table> | ||
|
||
|
||
https://docs.structurizr.com/dsl/example | ||
https://c4model.com/#DeploymentDiagram | ||
https://docs.structurizr.com/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import hiearch |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
def get_key(edge): | ||
return f'{edge["link"][0]}.{edge["link"][1]}' | ||
|
||
|
||
def parse(yaml_edges, edges, must_exist_nodes): | ||
default = { | ||
'link': ['', ''], | ||
'style': None, | ||
'graphviz': {}, | ||
# overriden | ||
'id': None, | ||
'in': None, | ||
'out': None, | ||
'scope_in': None, | ||
'scope_out': None | ||
} | ||
|
||
|
||
for edge in yaml_edges: | ||
key = get_key(edge) | ||
|
||
if key in edges.keys(): | ||
raise RuntimeError(f'Duplicate edge id: {key} | file: {filename}') | ||
|
||
edge['out'] = edge['link'][0] | ||
edge['in'] = edge['link'][1] | ||
must_exist_nodes.add(edge['in']) | ||
must_exist_nodes.add(edge['out']) | ||
|
||
edges[key] = default | edge | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import os | ||
# https://graphviz.readthedocs.io/en/stable/api.html | ||
import graphviz | ||
|
||
from . import node as hh_node | ||
|
||
|
||
def generate_tree(graph, tree, nodes): | ||
if len(tree) > 0: | ||
for node_key, node_tuple in tree.items(): | ||
node = nodes[node_key] | ||
|
||
if 0 == len(node_tuple['subtree']): | ||
graph.node(name=node_tuple['key_path'], label=hh_node.get_formatted_node_label(node), **node['graphviz']) | ||
else: | ||
with graph.subgraph( | ||
name=node_tuple['key_path'], | ||
graph_attr={'label': hh_node.get_formatted_scope_label(node), 'cluster': 'true'}) as subgraph: | ||
generate_tree(subgraph, node_tuple['subtree'], nodes) | ||
|
||
|
||
|
||
def generate(directory, fmt, view, nodes, edges): | ||
graph = graphviz.Digraph(name=view['id'], directory=directory) | ||
|
||
generate_tree(graph, view['tree'], nodes) | ||
|
||
for edge_set in ['edges', 'custom_edges']: | ||
for edge in view[edge_set].values(): | ||
# adjust edges that connect scopes | ||
if edge['out'] in view['scopes']: | ||
scope = edge['out'] | ||
for new_out in view['scopes'][scope]: | ||
# https://stackoverflow.com/questions/59825/how-to-retrieve-an-element-from-a-set-without-removing-it | ||
break | ||
edge_out = new_out | ||
edge['graphviz']['ltail'] = scope | ||
edge['graphviz']['tailclip'] = 'false' # workaround for bad angle of the arrow head | ||
else: | ||
edge_out = edge['out'] | ||
|
||
if edge['in'] in view['scopes']: | ||
scope = edge['in'] | ||
for new_in in view['scopes'][scope]: | ||
# https://stackoverflow.com/questions/59825/how-to-retrieve-an-element-from-a-set-without-removing-it | ||
break | ||
edge_in = new_in | ||
edge['graphviz']['lhead'] = scope | ||
edge['graphviz']['headclip'] = 'false' # workaround for bad angle of the arrow head | ||
else: | ||
edge_in = edge['in'] | ||
|
||
|
||
tail = '' | ||
head = '' | ||
best_match = -1 | ||
|
||
for tail_candidate in view['node_key_paths'][edge_out]: | ||
for head_candidate in view['node_key_paths'][edge_in]: | ||
current_match = len(os.path.commonprefix([tail_candidate, head_candidate])) | ||
if current_match > best_match: | ||
tail = tail_candidate | ||
head = head_candidate | ||
best_match = current_match | ||
|
||
graph.edge(tail_name=tail, head_name=head, **edge['graphviz']) | ||
|
||
for attr_group in ['graph', 'node', 'edge']: | ||
# https://graphviz.org/docs/nodes/ | ||
# https://graphviz.org/docs/edges/ | ||
# https://graphviz.org/docs/graph/ | ||
if attr_group in view['graphviz']: | ||
graph.attr(attr_group, **view['graphviz'][attr_group]) | ||
graph.graph_attr['compound'] = 'true' | ||
|
||
graph.render(format=fmt) | ||
os.rename(f'{directory}/{view["id"]}.gv.{fmt}', f'{directory}/{view["id"]}.{fmt}') # should not be needed in newer versions | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
#!/usr/bin/env python3 | ||
|
||
import argparse | ||
import yaml | ||
|
||
from . import node as node | ||
from . import edge as edge | ||
from . import view as view | ||
from . import graphviz | ||
|
||
|
||
|
||
def parse(filenames): | ||
nodes = {} | ||
edges = {} | ||
views = {} | ||
must_exist_nodes = set() | ||
must_exist_views = set() | ||
styled_nodes = [] | ||
styled_views = [] | ||
|
||
|
||
for filename in filenames: | ||
print(f'Processing {filename}') | ||
with open(filename, encoding='utf-8') as file: | ||
data = yaml.load(file, Loader=yaml.SafeLoader) | ||
|
||
if 'nodes' in data: | ||
node.parse(data['nodes'], nodes, must_exist_nodes, styled_nodes) | ||
|
||
if 'edges' in data: | ||
edge.parse(data['edges'], edges, must_exist_nodes) | ||
|
||
if 'views' in data: | ||
view.parse(data['views'], views, must_exist_views, must_exist_nodes, styled_views) | ||
|
||
|
||
node.postprocess(nodes, must_exist_nodes, styled_nodes, edges) | ||
view.postprocess(views, must_exist_views, styled_views, nodes, edges) | ||
|
||
|
||
return nodes, edges, views | ||
|
||
|
||
def main(): | ||
parser = argparse.ArgumentParser(prog='hiearch', description='Generates diagrams') | ||
|
||
parser.add_argument('inputs', metavar='<filename>', type=str, nargs='+', help='Input files') | ||
parser.add_argument('-o', '--output', required=False, default='hiearch', help='Output directory') | ||
parser.add_argument('-f', '--format', required=False, default='svg', help='Outpout format') | ||
|
||
args = parser.parse_args() | ||
|
||
nodes, edges, views = parse(args.inputs) | ||
|
||
for view in views.values(): | ||
if len(view['nodes']) > 0: | ||
graphviz.generate(args.output, args.format, view, nodes, edges) | ||
|
||
|
||
|
||
if __name__ == "__main__": | ||
main() |
Oops, something went wrong.