Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
asherikov committed Oct 23, 2023
0 parents commit d0e40e4
Show file tree
Hide file tree
Showing 35 changed files with 1,067 additions and 0 deletions.
16 changes: 16 additions & 0 deletions .github/workflows/master.yml
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

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build
67 changes: 67 additions & 0 deletions Makefile
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/
128 changes: 128 additions & 0 deletions README.md
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/
1 change: 1 addition & 0 deletions hiearch/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import hiearch
31 changes: 31 additions & 0 deletions hiearch/edge.py
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

79 changes: 79 additions & 0 deletions hiearch/graphviz.py
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


63 changes: 63 additions & 0 deletions hiearch/hiearch.py
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()
Loading

0 comments on commit d0e40e4

Please sign in to comment.