From bb8e5a64b5a280581ee5f846742f19eb59b5d3fa Mon Sep 17 00:00:00 2001 From: Alexander Sherikov Date: Sun, 17 Sep 2023 17:17:11 +0400 Subject: [PATCH] Initial commit --- .github/workflows/master.yml | 16 + .gitignore | 1 + Makefile | 75 +++ README.md | 529 ++++++++++++++++++ hiearch/__init__.py | 1 + hiearch/graphviz.py | 85 +++ hiearch/hh_edge.py | 82 +++ hiearch/hh_node.py | 181 ++++++ hiearch/hh_view.py | 146 +++++ hiearch/hiearch.py | 67 +++ hiearch/util.py | 41 ++ pyproject.toml | 19 + test/01_basic/01_basic.yaml | 40 ++ test/01_basic/direct.gv | 13 + test/01_basic/explicit.gv | 4 + test/01_basic/parent.gv | 6 + test/02_default_view/02_default_view.yaml | 13 + test/02_default_view/default.gv | 10 + .../03_default_view_split1.yaml | 9 + .../03_default_view_split2.yaml | 3 + test/03_default_view_split/default.gv | 10 + test/04_node_cycle/04_node_cycle.yaml | 9 + test/05_style_cycle/05_style_cycle.yaml | 9 + test/06_multiscope/06_multiscope.yaml | 10 + test/06_multiscope/default.gv | 15 + test/06_multiscope/default.svg | 46 ++ test/07_trivial/input.yaml | 9 + test/07_trivial/view1.gv | 5 + test/07_trivial/view1.svg | 25 + test/08_node_realations/input.yaml | 18 + test/08_node_realations/view1.gv | 6 + test/08_node_realations/view1.svg | 31 + test/08_node_realations/view2.gv | 8 + test/08_node_realations/view2.svg | 30 + test/08_node_realations/view3.gv | 5 + test/08_node_realations/view3.svg | 25 + test/09_tags/input.yaml | 12 + test/09_tags/view1.gv | 5 + test/09_tags/view1.svg | 25 + test/09_tags/view2.gv | 5 + test/09_tags/view2.svg | 25 + test/10_minimal/default.gv | 4 + test/10_minimal/default.svg | 19 + test/10_minimal/input.yaml | 2 + test/11_neighbors/input.yaml | 28 + test/11_neighbors/view1.gv | 4 + test/11_neighbors/view1.svg | 19 + test/11_neighbors/view2.gv | 6 + test/11_neighbors/view2.svg | 31 + test/11_neighbors/view3.gv | 6 + test/11_neighbors/view3.svg | 31 + test/11_neighbors/view4.gv | 9 + test/11_neighbors/view4.svg | 36 ++ test/12_view_style/input.yaml | 25 + test/12_view_style/plain.gv | 5 + test/12_view_style/plain.svg | 25 + test/12_view_style/styled.gv | 8 + test/12_view_style/styled.svg | 26 + test/13_edge_labels/input.yaml | 17 + test/13_edge_labels/view1.gv | 5 + test/13_edge_labels/view1.svg | 26 + test/13_edge_labels/view2.gv | 5 + test/13_edge_labels/view2.svg | 28 + test/14_edge_style/input.yaml | 28 + test/14_edge_style/view1.gv | 5 + test/14_edge_style/view1.svg | 26 + test/14_edge_style/view2.gv | 5 + test/14_edge_style/view2.svg | 27 + test/14_edge_style/view3.gv | 5 + test/14_edge_style/view3.svg | 27 + test/15_formatted_labels/input.yaml | 21 + test/15_formatted_labels/view1.gv | 4 + test/15_formatted_labels/view1.png | Bin 0 -> 11351 bytes test/15_formatted_labels/view2.gv | 7 + test/15_formatted_labels/view2.png | Bin 0 -> 11390 bytes test/15_formatted_labels/view3.gv | 4 + test/15_formatted_labels/view3.png | Bin 0 -> 10725 bytes 77 files changed, 2198 insertions(+) create mode 100644 .github/workflows/master.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 hiearch/__init__.py create mode 100644 hiearch/graphviz.py create mode 100644 hiearch/hh_edge.py create mode 100644 hiearch/hh_node.py create mode 100644 hiearch/hh_view.py create mode 100644 hiearch/hiearch.py create mode 100644 hiearch/util.py create mode 100644 pyproject.toml create mode 100644 test/01_basic/01_basic.yaml create mode 100644 test/01_basic/direct.gv create mode 100644 test/01_basic/explicit.gv create mode 100644 test/01_basic/parent.gv create mode 100644 test/02_default_view/02_default_view.yaml create mode 100644 test/02_default_view/default.gv create mode 100644 test/03_default_view_split/03_default_view_split1.yaml create mode 100644 test/03_default_view_split/03_default_view_split2.yaml create mode 100644 test/03_default_view_split/default.gv create mode 100644 test/04_node_cycle/04_node_cycle.yaml create mode 100644 test/05_style_cycle/05_style_cycle.yaml create mode 100644 test/06_multiscope/06_multiscope.yaml create mode 100644 test/06_multiscope/default.gv create mode 100644 test/06_multiscope/default.svg create mode 100644 test/07_trivial/input.yaml create mode 100644 test/07_trivial/view1.gv create mode 100644 test/07_trivial/view1.svg create mode 100644 test/08_node_realations/input.yaml create mode 100644 test/08_node_realations/view1.gv create mode 100644 test/08_node_realations/view1.svg create mode 100644 test/08_node_realations/view2.gv create mode 100644 test/08_node_realations/view2.svg create mode 100644 test/08_node_realations/view3.gv create mode 100644 test/08_node_realations/view3.svg create mode 100644 test/09_tags/input.yaml create mode 100644 test/09_tags/view1.gv create mode 100644 test/09_tags/view1.svg create mode 100644 test/09_tags/view2.gv create mode 100644 test/09_tags/view2.svg create mode 100644 test/10_minimal/default.gv create mode 100644 test/10_minimal/default.svg create mode 100644 test/10_minimal/input.yaml create mode 100644 test/11_neighbors/input.yaml create mode 100644 test/11_neighbors/view1.gv create mode 100644 test/11_neighbors/view1.svg create mode 100644 test/11_neighbors/view2.gv create mode 100644 test/11_neighbors/view2.svg create mode 100644 test/11_neighbors/view3.gv create mode 100644 test/11_neighbors/view3.svg create mode 100644 test/11_neighbors/view4.gv create mode 100644 test/11_neighbors/view4.svg create mode 100644 test/12_view_style/input.yaml create mode 100644 test/12_view_style/plain.gv create mode 100644 test/12_view_style/plain.svg create mode 100644 test/12_view_style/styled.gv create mode 100644 test/12_view_style/styled.svg create mode 100644 test/13_edge_labels/input.yaml create mode 100644 test/13_edge_labels/view1.gv create mode 100644 test/13_edge_labels/view1.svg create mode 100644 test/13_edge_labels/view2.gv create mode 100644 test/13_edge_labels/view2.svg create mode 100644 test/14_edge_style/input.yaml create mode 100644 test/14_edge_style/view1.gv create mode 100644 test/14_edge_style/view1.svg create mode 100644 test/14_edge_style/view2.gv create mode 100644 test/14_edge_style/view2.svg create mode 100644 test/14_edge_style/view3.gv create mode 100644 test/14_edge_style/view3.svg create mode 100644 test/15_formatted_labels/input.yaml create mode 100644 test/15_formatted_labels/view1.gv create mode 100644 test/15_formatted_labels/view1.png create mode 100644 test/15_formatted_labels/view2.gv create mode 100644 test/15_formatted_labels/view2.png create mode 100644 test/15_formatted_labels/view3.gv create mode 100644 test/15_formatted_labels/view3.png diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 0000000..d5d307a --- /dev/null +++ b/.github/workflows/master.yml @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..378eac2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..336d90d --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +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_DIR?=${CURRENT_DIR}/test +TEST_NOT= +FORMAT=svg + +.DEFAULT: + @echo "Testing $@..." + cd ${TEST_DIR}/$@/; ${TEST_NOT} hiearch -f ${FORMAT} -o ${BUILD_DIR}/$@ *.yaml + mkdir -p ${BUILD_DIR}/$@ + cp ${TEST_DIR}/$@/icon*.svg ${BUILD_DIR}/$@/ || true + # TODO awkward and fragile + find ${BUILD_DIR}/$@/ -iname '*.gv' | sort | xargs --no-run-if-empty -I {} sh -c "sort {} | md5sum > ${BUILD_DIR}/$@/checksum.build" + find ${TEST_DIR}/$@/ -iname '*.gv' | sort | xargs --no-run-if-empty -I {} sh -c "sort {} | md5sum > ${BUILD_DIR}/$@/checksum.test" + ${TEST_NOT} cmp ${BUILD_DIR}/$@/checksum.build ${BUILD_DIR}/$@/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 09_tags 10_minimal \ + 11_neighbors 12_view_style 13_edge_labels 14_edge_style \ + 15_formatted_labels || (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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1db32b4 --- /dev/null +++ b/README.md @@ -0,0 +1,529 @@ +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 . 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 + : `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 +======== + +Command line options +-------------------- + +``` +usage: hiearch [-h] [-o OUTPUT] [-f FORMAT] [ ...] + +Generates diagrams + +positional arguments: + Input files + +optional arguments: + -h, --help show this help message and exit + -o OUTPUT, --output OUTPUT + Output directory [hiearch] + -f FORMAT, --format FORMAT + Output format [SVG] +``` + + +Trivial +------- + + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]  # [label, unique id]
+edges:
+    - link: [test1, test1]   # [from node id, to node id]
+views:
+    - id: view1              # unique id / output filename
+      nodes: [test1]         # nodes to include
+            
+
+ view1 +
+ view1 +
+ + +Node relations +-------------- + + + + + + + + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]
+    - id: ["Test 2", test2]
+      graphviz:  # set graphviz attributes directly
+        fillcolor: aqua
+        style: filled
+    - id: ["Test 3", test3]
+      scope: test1  # test3 is contained in test1
+      style: test2  # test3 inherits all test2 attributes
+edges:
+    - link: [test3, test3]
+views:
+    - id: view1
+      nodes: [test2, test3]
+    - id: view2  # test1 is shown as subgraph
+      nodes: [test1, test3]
+    - id: view3
+      nodes: [test1, test2]
+            
+
+ view1 +
+ view1 +
+ view2 +
+ view2 +
+ view3 +
+ view3 +
+ + +Node selection using tags +------------------------- + + + + + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]
+      # tags: ["default"] if not specified
+    - id: ["Test 2", test2]
+      tags: ["test2_tag"]
+edges:
+    - link: [test1, test1]
+    - link: [test2, test2]
+views:
+    - id: view1
+      tags: ["test2_tag"]
+    - id: view2
+      tags: ["default"]
+            
+
+ view1 +
+ view1 +
+ view2 +
+ view2 +
+ + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]
+# a default view is added with 'tags: ["default"]'
+# if no explicit views are specified
+            
+
+ default +
+ default +
+ + + +Neighbour node selection +------------------------ + + + + + + + + + + + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]
+    - id: ["Test 2", test2]
+    - id: ["Test 3", test3]
+      scope: test2
+edges:
+    - link: [test1, test3]
+views:
+    - id: view1
+      nodes: [test1]
+      # nodes must be specified explicitly
+      # neighbours: explicit
+    - id: view2
+      nodes: [test1]
+      # add connected nodes
+      neighbours: direct
+    - id: view3
+      nodes: [test1]
+      # add top most parents of connected nodes
+      neighbours: parent
+    - id: view4
+      # all three together
+      tags: ["default"]
+            
+
+ view1 +
+ view1 +
+ view2 +
+ view2 +
+ view3 +
+ view3 +
+ view4 +
+ view4 +
+ + + +View styles +----------- + + + + + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]
+edges:
+    - link: [test1, test1]
+views:
+    - id: style
+      nodes: []  # explicitly empty view is not rendered
+      # defaults, overriden by node/edge attributes
+      graphviz:
+          graph:
+              style: filled
+              bgcolor: coral
+          node:
+              fontsize: "24"
+              fontname: times
+          edge:
+              dir: both
+    - id: styled
+      nodes: [test1]
+      style: style  # inherit style from another view
+    - id: plain
+      nodes: [test1]
+            
+
+ styled +
+ styled +
+ plain +
+ plain +
+ + + +Edge labels +----------- + + + + + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]
+    - id: ["Test 2", test2]
+edges:
+    - link: [test1, test1]
+      label: 'test1_edge'
+    - link: [test2, test2]
+      label: ['tail', 'middle', 'head']
+views:
+    - id: view1
+      nodes: [test1]
+    - id: view2
+      nodes: [test2]
+            
+
+ view1 +
+ view1 +
+ view2 +
+ view2 +
+ + +Edge styles +----------- + + + + + + + + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    - id: ["Test 1", test1]
+    - id: ["Test 2", test2]
+    - id: ["Test 3", test3]
+edges:
+    - link: [test1, test1]
+      label: 'test1'
+      graphviz:
+        color: red
+    # third link parameters indroduces explicit id, which
+    # must be unique
+    - link: [test2, test2, edge2]
+      # style can be referenced by link attribute
+      style: [test1, test1]
+      graphviz:
+        dir: both
+    - link: [test3, test3]
+      # style can also be an explicit id
+      style: edge2
+      graphviz:
+        color: blue
+views:
+    - id: view1
+      nodes: [test1]
+    - id: view2
+      nodes: [test2]
+    - id: view3
+      nodes: [test3]
+            
+
+ view1 +
+ view1 +
+ view2 +
+ view2 +
+ view3 +
+ view3 +
+ + + +Formatted labels +---------------- + +``` +nodes: + - id: ["Test 1", test1] + # icons were obtained from + # https://www.svgrepo.com/svg/479843/duck-toy-illustration-3 + # https://www.svgrepo.com/svg/479405/casa-pictogram-5 + graphviz: + node_label_format: '<
{label}
>' + scope_label_format: '<
Scope: {label}
>' + - id: ["Test 2", test2] + scope: test1 + - id: ["Test 3", test3] + tags: [] + graphviz: + node_label_format: '<
{label}
>' + - id: ["Test 4", test4] + style: test3 +views: + - id: view1 + nodes: [test1] + - id: view2 + nodes: [test1, test2] + - id: view3 + nodes: [test4] +``` + +Note that SVG with other embedded SVG is not always rendered properly, and +embedded pictures may get lost during conversion. The PNG files below were +generated with `rsvg-convert view1.svg --format=png --output=view1.png`, +exporting directly to PNG won't work. + + + + + + + + +
+ + view1 +
+ view1 +
+ view2 +
+ view2 +
+ view3 +
+ view3 +
+ + + +Multiscoping +------------ + + + + + +
+
+-----------------------------------------------------------
+nodes:
+    # root nodes
+    - id: ["Test 1", test1]
+    - id: ["Test 2", test2]
+    # child nodes
+    - id: ["Test 3", test3]
+      # a child of both root nodes: if both scopes are
+      # present in a view they are automatically ranked
+      # to form a hierarchy
+      scope: [test1, test2]
+    # Both root nodes also include non-shared nodes.
+    # Since is not possible to visualize overlaping
+    # subgraphs with graphviz, one of them is going to be
+    # divided into two parts.
+    - id: ["Test 4", test4]
+      scope: test2
+    - id: ["Test 5", test5]
+      scope: [test1]
+            
+
+ default +
+ default +
+ + + +Advanced styling +---------------- + + +https://docs.structurizr.com/dsl/example +https://c4model.com/#DeploymentDiagram +https://docs.structurizr.com/ diff --git a/hiearch/__init__.py b/hiearch/__init__.py new file mode 100644 index 0000000..870b67d --- /dev/null +++ b/hiearch/__init__.py @@ -0,0 +1 @@ +from . import hiearch diff --git a/hiearch/graphviz.py b/hiearch/graphviz.py new file mode 100644 index 0000000..cea9de2 --- /dev/null +++ b/hiearch/graphviz.py @@ -0,0 +1,85 @@ +import os +# https://graphviz.readthedocs.io/en/stable/api.html +import graphviz + +from . import hh_node +from . import hh_edge + + +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'], **hh_node.get_graphviz_node_attributes(node)) + 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) + + 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' + + 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 + + labels = hh_edge.get_formatted_label(edge) + for label, attr in zip(labels, ['taillabel', 'label', 'headlabel']): + if len(label) > 0: + edge['graphviz'][attr] = label + + graph.edge(tail_name=tail, head_name=head, **edge['graphviz']) + + graph.render(format=fmt) + os.rename(f'{directory}/{view["id"]}.gv.{fmt}', f'{directory}/{view["id"]}.{fmt}') # should not be needed in newer versions + + diff --git a/hiearch/hh_edge.py b/hiearch/hh_edge.py new file mode 100644 index 0000000..aee9e6f --- /dev/null +++ b/hiearch/hh_edge.py @@ -0,0 +1,82 @@ +from . import util + + +def generate_id(edge): + edge['id'] = f'{edge["out"]}.{edge["in"]}' + + +def get_style_key(style): + if isinstance(style, list): + return f'{style[0]}.{style[1]}' + else: + return style + + +def parse(yaml_edges, edges, must_exist_nodes): + default = { + 'link': ['', ''], + 'style': None, + 'graphviz': {}, + 'label': [], + 'label_format': ['{label}', '{label}', '{label}'], + # overriden + 'id': None, + 'in': None, + 'out': None, + 'scope_in': None, + 'scope_out': None + } + + + for edge in yaml_edges: + edge['out'] = edge['link'][0] + edge['in'] = edge['link'][1] + if len(edge['link']) > 2: + edge['id'] = edge['link'][2] + else: + generate_id(edge) + + key = edge['id'] + + if key in edges.entities.keys(): + raise RuntimeError(f'Duplicate edge id: {key} | file: {filename}') + + must_exist_nodes.add(edge['in']) + must_exist_nodes.add(edge['out']) + + if 'style' in edge: + edge['style'] = get_style_key(edge['style']) + edges.must_exist.add(edge['style']) + edges.styled.append(edge) + + edges.entities[key] = edge + else: + edges.entities[key] = util.merge_styles(default, edge) + + +def get_formatted_label(edge): + labels = [] + for label, fmt in zip(edge['label'], edge['label_format']): + labels.append(fmt.format( + label=label, + id=edge['id'], + node_in=edge['in'], + node_out=edge['out'], + scope_in=edge['scope_in'], + scope_out=edge['scope_out'], + style=edge['style'])) + + return labels + + +def postprocess(edges): + util.check_key_existence(edges.must_exist, edges.entities, 'edge') + util.apply_styles(edges.styled, edges.entities) + + + for edge in edges.entities.values(): + if isinstance(edge['label'], str): + edge['label'] = ['', edge['label'], ''] + if isinstance(edge['label_format'], str): + edge['label_format'] = ['{label}', edge['label_format'], '{label}'] + diff --git a/hiearch/hh_node.py b/hiearch/hh_node.py new file mode 100644 index 0000000..7fe0a00 --- /dev/null +++ b/hiearch/hh_node.py @@ -0,0 +1,181 @@ +import copy +from collections import defaultdict + +from . import util + + +def gather(prop, node, must_exist_nodes): + if prop in node.keys() and node[prop] is not None: + if isinstance(node[prop], list): + must_exist_nodes = must_exist_nodes.union(set(node[prop])) + else: + must_exist_nodes.add(node[prop]) + return True + return False + + +def postprocess(nodes, edges): + util.check_key_existence(nodes.must_exist, nodes.entities, 'node') + util.apply_styles(nodes.styled, nodes.entities) + + for node in nodes.entities.values(): + node['out'] = set() + node['in'] = set() + node['child_out'] = set() + node['child_in'] = set() + + original_scope = node['scope'] + if original_scope is not None: + if isinstance(original_scope, list): + node['scope'] = set(original_scope) + + if not original_scope: + raise RuntimeError(f'Duplicate scopes: {key} | scopes: {original_scope} | file: {filename}XXXXX') + if len(original_scope) != len(node['scope']): + raise RuntimeError(f'Duplicate scopes: {key} | scopes: {original_scope} | file: {filename}') + else: + node['scope'] = set([original_scope]) + + for key, edge in edges.items(): + for dir_key in ['in', 'out']: + nodes.entities[edge[dir_key]][dir_key].add(key) + + +def add_branch_to_tree(branch, tree, node_key_paths, scopes, index=0): + if index == len(branch): + return tree + else: + node_key = branch[index] + if index + 1 < len(branch): + if node_key in scopes: + scopes[node_key].add(branch[index + 1]) + else: + scopes[node_key] = set([branch[index + 1]]) + + if len(tree) > 0 and node_key in tree.keys(): + tree[node_key] = { + 'subtree': add_branch_to_tree(branch, tree[node_key]['subtree'], node_key_paths, scopes, index + 1), + 'key_path': tree[node_key]['key_path'] + } + else: + tree[node_key] = { + 'subtree': add_branch_to_tree(branch, {}, node_key_paths, scopes, index + 1), + 'key_path': '.'.join(branch[:index + 1]) + } + + node_key_paths[node_key].add(tree[node_key]['key_path']) + return tree + + +def build_tree(nodes, nodes_view): + rank = defaultdict(lambda: 0) + + branches = defaultdict(lambda: []) + branch = [None] + scope_stack = [set(nodes_view)] + + nonleaf = set() + + while len(scope_stack) > 0: + branch.pop() + updated_branch = False + while scope_stack[-1] is not None and len(scope_stack[-1]) > 0: + scope = scope_stack[-1].pop() + + if scope in branch: + raise RuntimeError(f'Detected cycle in branch: {branch} | {scope}') + if scope in nodes_view: + rank['scope'] += 1 + updated_branch = True + branch.append(scope) + if len(branch) > 1: + nonleaf.add(scope) + + scope_stack.append(copy.deepcopy(nodes[scope]['scope'])) + + if updated_branch: + branches[branch[0]].append(copy.deepcopy(branch)) + + scope_stack.pop() + + + scopes = {} + node_tree = {} + node_key_paths = defaultdict(lambda: set()) + for key, branch_list in branches.items(): + # a node may appear both as a leaf and nonleaf in a view with + # multiscoping, in such cases we should omit the leaf due to + # redundancy + if key not in nonleaf: + branch = branch_list[0] + for branch_merge in branch_list[1:]: + index = 0 + for node in branch_merge: + # if we reached the end of the original branch, or the nodes do not match + # find a place where the node must be inserted + if index == len(branch) or node != branch[index]: + while index < len(branch) and rank[node] > rank[branch[index]]: + index += 1 + if rank[node] == rank[branch[index]] and node > branch[index]: + index += 1 + branch.insert(index, node) + index += 1 + + branch.reverse() + node_tree = add_branch_to_tree(branch, node_tree, node_key_paths, scopes) + + + return node_tree, node_key_paths, scopes + + +def parse(yaml_nodes, nodes): + default = { + 'id': ['', ''], + 'scope': None, + 'style': None, + 'graphviz': { + 'node_label_format': '{label}', + 'scope_label_format': '{label}', + }, + 'tags': ['default'], + # overriden + 'label': '', + 'in': [], + 'out': [], + 'child_in': [], + 'child_out': [], + } + + for node in yaml_nodes: + node['label'] = node['id'][0] + node['id'] = node['id'][1] + key = node['id'] + if key in nodes.entities.keys(): + raise RuntimeError(f'Duplicate node id: {key} | file: {filename}') + + gather('scope', node, nodes.must_exist) + if gather('style', node, nodes.must_exist): + nodes.styled.append(node) + nodes.entities[key] = node + else: + nodes.entities[key] = util.merge_styles(default, node) + + +def get_graphviz_node_attributes(node): + attrs = copy.deepcopy(node['graphviz']) + attrs['label'] = node['graphviz']['node_label_format'].format(label=node['label'], id=node['id'], scope=node['scope'], style=node['style']) + attrs.pop('node_label_format') + attrs.pop('scope_label_format') + return attrs + + +def get_formatted_scope_label(node): + return node['graphviz']['scope_label_format'].format(label=node['label'], id=node['id'], scope=node['scope'], style=node['style']) + + +def get_nodes_by_tag(nodes, tag): + selection = [] + for node in nodes.values(): + if tag in node['tags']: + selection.append(node['id']) + return set(selection) diff --git a/hiearch/hh_view.py b/hiearch/hh_view.py new file mode 100644 index 0000000..f2e8700 --- /dev/null +++ b/hiearch/hh_view.py @@ -0,0 +1,146 @@ +import copy + +from . import hh_node +from . import hh_edge +from . import util + + +class Neighbours(): + DIRECT = 'direct' + EXPLICIT = 'explicit' + PARENT = 'parent' + + types = [DIRECT, EXPLICIT, PARENT] + + + +default = { + 'id': 'default', + 'nodes': None, + 'neighbours': Neighbours.EXPLICIT, + 'graphviz': {}, + 'style': None, + 'tags': [], + # overriden + 'edges': [], + 'custom_edges': [], + 'tree': {}, +} + + +opposite = { + 'in': 'out', + 'out': 'in', +} + + +def postprocess(views, nodes, edges): + util.check_key_existence(views.must_exist, views.entities, 'view') + util.apply_styles(views.styled, views.entities) + + + if 0 == len(views.entities): + views.entities['default'] = default + views.entities['default']['tags'] = ['default'] + + empty_views_counter = 0 + for view in views.entities.values(): + if view['nodes'] is None: + view['nodes'] = set() + else: + num_nodes = len(view['nodes']) + view['nodes'] = set(view['nodes']) # set converted to list by | operator in apply_styles() + if len(view['nodes']) != num_nodes: + raise RuntimeError(f'Duplicate node ids in view: {view["id"]} | file: {filename} | nodes: [{view["nodes"]}] != [{views[key]["nodes"]}]') + + for tag in view['tags']: + view['nodes'] = view['nodes'].union(hh_node.get_nodes_by_tag(nodes, tag)) + + if 0 == len(view['nodes']): + empty_views_counter += 1 + continue + + + view['edges'] = {} + view['custom_edges'] = {} + add_nodes = set() + + if Neighbours.EXPLICIT == view['neighbours']: + for node_key in view['nodes']: + for dir_key in ['in', 'out']: + for edge_key in nodes[node_key][dir_key]: + if edges[edge_key][opposite[dir_key]] in view['nodes']: + view['edges'][edge_key] = copy.deepcopy(edges[edge_key]) + + elif Neighbours.DIRECT == view['neighbours']: + for node_key in view['nodes']: + for dir_key in ['in', 'out']: + for edge_key in nodes[node_key][dir_key]: + view['edges'][edge_key] = copy.deepcopy(edges[edge_key]) + + for edge_key in view['edges']: + for dir_key in ['in', 'out']: + add_nodes.add(edges[edge_key][dir_key]) + + elif Neighbours.PARENT == view['neighbours']: + add_nodes = set() + + for node_key in view['nodes']: + for dir_key in ['in', 'out']: + opp_dir_key = opposite[dir_key] + for edge_key in nodes[node_key][dir_key]: + nodes_to_explore = set([edges[edge_key][opp_dir_key]]) + + while len(nodes_to_explore) > 0: + scope_node_key = nodes_to_explore.pop() + + # go up the tree until reached an explicitly requested or a root node + while scope_node_key not in view['nodes'] and nodes[scope_node_key]['scope'] is not None: + scope_copy = copy.deepcopy(nodes[scope_node_key]['scope']) + scope_node_key = scope_copy.pop() + nodes_to_explore = nodes_to_explore.union(scope_copy) + + if scope_node_key not in add_nodes: # same node can be reached in multiple ways + add_nodes.add(scope_node_key) + if scope_node_key == edges[edge_key][opp_dir_key]: + view['edges'][edge_key] = copy.deepcopy(edges[edge_key]) + else: + # generate edge with a parent + new_edge = copy.deepcopy(edges[edge_key]) + new_edge[opp_dir_key] = scope_node_key + hh_edge.generate_id(new_edge) + view['custom_edges'][new_edge['id']] = new_edge + + else: + raise RuntimeError(f'Unsupported neighbours type: {view["neighbours"]}, must be one of [{Neighbours.types}].') + + if len(add_nodes) > 0: + view['nodes'] = view['nodes'].union(add_nodes) + + + view['tree'], view['node_key_paths'], view['scopes'] = hh_node.build_tree(nodes, view['nodes']) + + + if empty_views_counter == len(views.entities): + raise RuntimeError(f'All views are empty: {views.entities.keys()}') + + +def parse(yaml_views, views, must_exist_nodes): + for view in yaml_views: + key = view['id'] + + if key in views.entities.keys(): + raise RuntimeError(f'Duplicate view id: {key} | file: {filename}') + + if 'style' in view: + views.must_exist.add(view['style']) + views.styled.append(view) + + views.entities[key] = view + else: + views.entities[key] = util.merge_styles(default, view) + + if 'nodes' in view: + for node in view['nodes']: + must_exist_nodes.add(node) + diff --git a/hiearch/hiearch.py b/hiearch/hiearch.py new file mode 100644 index 0000000..8c93a0b --- /dev/null +++ b/hiearch/hiearch.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 + +import argparse +import yaml + +from . import hh_node +from . import hh_edge +from . import hh_view +from . import graphviz + + +class ParsedEntities: + def __init__(self): + self.entities = {} + self.must_exist = set() + self.styled = [] + + + +def parse(filenames): + nodes = ParsedEntities() + edges = ParsedEntities() + views = ParsedEntities() + + + 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: + hh_node.parse(data['nodes'], nodes) + + if 'edges' in data: + hh_edge.parse(data['edges'], edges, nodes.must_exist) + + if 'views' in data: + hh_view.parse(data['views'], views, nodes.must_exist) + + + hh_edge.postprocess(edges) + hh_node.postprocess(nodes, edges.entities) + hh_view.postprocess(views, nodes.entities, edges.entities) + + + return nodes.entities, edges.entities, views.entities + + +def main(): + parser = argparse.ArgumentParser(prog='hiearch', description='Generates diagrams') + + parser.add_argument('inputs', metavar='', type=str, nargs='+', help='Input files') + parser.add_argument('-o', '--output', required=False, default='hiearch', help='Output directory [hiearch]') + parser.add_argument('-f', '--format', required=False, default='svg', help='Output format [SVG]') + + 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() diff --git a/hiearch/util.py b/hiearch/util.py new file mode 100644 index 0000000..059906b --- /dev/null +++ b/hiearch/util.py @@ -0,0 +1,41 @@ +import copy + + +def merge_styles(secondary, primary): + if 'graphviz' in secondary: + if 'graphviz' in primary: + primary['graphviz'] = secondary['graphviz'] | primary['graphviz'] + else: + primary['graphviz'] = secondary['graphviz'] + + return secondary | primary + + +def apply_styles(styled_entities, entities): + size = len(styled_entities) + nodes_style_applied = set() + + while size > 0: + index = 0 + size_copy = copy.deepcopy(size) + while index < size: + father_entity = entities[styled_entities[index]['style']] + + if father_entity['style'] is None or father_entity['id'] in nodes_style_applied: + key = styled_entities[index]['id'] + entities[key] = merge_styles(father_entity, styled_entities[index]) + nodes_style_applied.add(key) + styled_entities[index], styled_entities[size - 1] = styled_entities[size - 1], styled_entities[index] + size -= 1 + else: + branch = [] + index += 1 + if size_copy == size: + raise RuntimeError(f'Style cycle detected: {styled_entities}') + + +def check_key_existence(keys, dictionary, data_type): + for key in keys: + if key not in dictionary.keys(): + raise RuntimeError(f'Missing {data_type} id: {key}') + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cdf363c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hiearch" +version = "0.0.1" +dependencies = [ + "graphviz", + "pyyaml" +] + +classifiers = [ + "Programming Language :: Python :: 3", +] + +[project.scripts] +hiearch = "hiearch.hiearch:main" + diff --git a/test/01_basic/01_basic.yaml b/test/01_basic/01_basic.yaml new file mode 100644 index 0000000..02846db --- /dev/null +++ b/test/01_basic/01_basic.yaml @@ -0,0 +1,40 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test1 + + - id: ["Test 3", test3] + style: test1 + scope: test2 + +edges: + - link: [test1, test2] + - link: [test1, test3] + +views: + - id: style + nodes: [] + graphviz: + graph: + rankdir: LR + compound: "true" + node: + fontsize: "18" + fontname: times + edge: + decorate: "true" + fontsize: "14" + + - id: explicit + nodes: [test1] + neighbours: explicit + + - id: direct + nodes: [test1] + neighbours: direct + style: style + + - id: parent + nodes: [test1] + neighbours: parent diff --git a/test/01_basic/direct.gv b/test/01_basic/direct.gv new file mode 100644 index 0000000..895b732 --- /dev/null +++ b/test/01_basic/direct.gv @@ -0,0 +1,13 @@ +digraph direct { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + "test2.test3" [label="Test 3"] + } + test1 [label="Test 1"] + test1 -> "test2.test3" [headclip=false lhead=test2] + test1 -> "test2.test3" + graph [compound=true rankdir=LR] + node [fontname=times fontsize=18] + edge [decorate=true fontsize=14] +} diff --git a/test/01_basic/explicit.gv b/test/01_basic/explicit.gv new file mode 100644 index 0000000..0e126fc --- /dev/null +++ b/test/01_basic/explicit.gv @@ -0,0 +1,4 @@ +digraph explicit { + graph [compound=true] + test1 [label="Test 1"] +} diff --git a/test/01_basic/parent.gv b/test/01_basic/parent.gv new file mode 100644 index 0000000..0cfe640 --- /dev/null +++ b/test/01_basic/parent.gv @@ -0,0 +1,6 @@ +digraph parent { + graph [compound=true] + test2 [label="Test 2"] + test1 [label="Test 1"] + test1 -> test2 +} diff --git a/test/02_default_view/02_default_view.yaml b/test/02_default_view/02_default_view.yaml new file mode 100644 index 0000000..22b1969 --- /dev/null +++ b/test/02_default_view/02_default_view.yaml @@ -0,0 +1,13 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test1 + + - id: ["Test 3", test3] + style: test1 + scope: test2 + +edges: + - link: [test1, test2] + - link: [test1, test3] diff --git a/test/02_default_view/default.gv b/test/02_default_view/default.gv new file mode 100644 index 0000000..00ef4af --- /dev/null +++ b/test/02_default_view/default.gv @@ -0,0 +1,10 @@ +digraph default { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + "test2.test3" [label="Test 3"] + } + test1 [label="Test 1"] + test1 -> "test2.test3" [headclip=false lhead=test2] + test1 -> "test2.test3" +} diff --git a/test/03_default_view_split/03_default_view_split1.yaml b/test/03_default_view_split/03_default_view_split1.yaml new file mode 100644 index 0000000..d4a1ccb --- /dev/null +++ b/test/03_default_view_split/03_default_view_split1.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test1 + + - id: ["Test 3", test3] + style: test1 + scope: test2 diff --git a/test/03_default_view_split/03_default_view_split2.yaml b/test/03_default_view_split/03_default_view_split2.yaml new file mode 100644 index 0000000..16b1ed3 --- /dev/null +++ b/test/03_default_view_split/03_default_view_split2.yaml @@ -0,0 +1,3 @@ +edges: + - link: [test1, test2] + - link: [test1, test3] diff --git a/test/03_default_view_split/default.gv b/test/03_default_view_split/default.gv new file mode 100644 index 0000000..3c57fac --- /dev/null +++ b/test/03_default_view_split/default.gv @@ -0,0 +1,10 @@ +digraph default { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + "test2.test3" [label="Test 3"] + } + test1 [label="Test 1"] + test1 -> "test2.test3" + test1 -> "test2.test3" [headclip=false lhead=test2] +} diff --git a/test/04_node_cycle/04_node_cycle.yaml b/test/04_node_cycle/04_node_cycle.yaml new file mode 100644 index 0000000..9591c77 --- /dev/null +++ b/test/04_node_cycle/04_node_cycle.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + scope: test3 + + - id: ["Test 3", test3] + scope: test2 + diff --git a/test/05_style_cycle/05_style_cycle.yaml b/test/05_style_cycle/05_style_cycle.yaml new file mode 100644 index 0000000..80ffb4c --- /dev/null +++ b/test/05_style_cycle/05_style_cycle.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + + - id: ["Test 2", test2] + style: test3 + + - id: ["Test 3", test3] + style: test2 + diff --git a/test/06_multiscope/06_multiscope.yaml b/test/06_multiscope/06_multiscope.yaml new file mode 100644 index 0000000..9879d7a --- /dev/null +++ b/test/06_multiscope/06_multiscope.yaml @@ -0,0 +1,10 @@ +nodes: + - id: ["Test 1", test1] + - id: ["Test 2", test2] + - id: ["Test 3", test3] + scope: [test1, test2] + - id: ["Test 4", test4] + scope: [test2] + - id: ["Test 5", test5] + scope: [test1] + diff --git a/test/06_multiscope/default.gv b/test/06_multiscope/default.gv new file mode 100644 index 0000000..e884297 --- /dev/null +++ b/test/06_multiscope/default.gv @@ -0,0 +1,15 @@ +digraph default { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + subgraph "test2.test1" { + graph [cluster=true label="Test 1"] + "test2.test1.test3" [label="Test 3"] + } + "test2.test4" [label="Test 4"] + } + subgraph test1 { + graph [cluster=true label="Test 1"] + "test1.test5" [label="Test 5"] + } +} diff --git a/test/06_multiscope/default.svg b/test/06_multiscope/default.svg new file mode 100644 index 0000000..554cce7 --- /dev/null +++ b/test/06_multiscope/default.svg @@ -0,0 +1,46 @@ + + + + + + +default + + +test1 + +Test 1 + + +test2 + +Test 2 + + +test2.test1 + +Test 1 + + + +test1.test5 + +Test 5 + + + +test2.test4 + +Test 4 + + + +test2.test1.test3 + +Test 3 + + + diff --git a/test/07_trivial/input.yaml b/test/07_trivial/input.yaml new file mode 100644 index 0000000..5d5c78f --- /dev/null +++ b/test/07_trivial/input.yaml @@ -0,0 +1,9 @@ +nodes: + - id: ["Test 1", test1] + +edges: + - link: [test1, test1] + +views: + - id: view1 + nodes: [test1] diff --git a/test/07_trivial/view1.gv b/test/07_trivial/view1.gv new file mode 100644 index 0000000..d38122c --- /dev/null +++ b/test/07_trivial/view1.gv @@ -0,0 +1,5 @@ +digraph view1 { + graph [compound=true] + test1 [label="Test 1"] + test1 -> test1 +} diff --git a/test/07_trivial/view1.svg b/test/07_trivial/view1.svg new file mode 100644 index 0000000..4bc2e59 --- /dev/null +++ b/test/07_trivial/view1.svg @@ -0,0 +1,25 @@ + + + + + + +view1 + + + +test1 + +Test 1 + + + +test1->test1 + + + + + diff --git a/test/08_node_realations/input.yaml b/test/08_node_realations/input.yaml new file mode 100644 index 0000000..1463e50 --- /dev/null +++ b/test/08_node_realations/input.yaml @@ -0,0 +1,18 @@ +nodes: + - id: ["Test 1", test1] + - id: ["Test 2", test2] + graphviz: + fillcolor: aqua + style: filled + - id: ["Test 3", test3] + scope: test1 + style: test2 +edges: + - link: [test3, test3] +views: + - id: view1 + nodes: [test2, test3] + - id: view2 + nodes: [test1, test3] + - id: view3 + nodes: [test1, test2] diff --git a/test/08_node_realations/view1.gv b/test/08_node_realations/view1.gv new file mode 100644 index 0000000..573de34 --- /dev/null +++ b/test/08_node_realations/view1.gv @@ -0,0 +1,6 @@ +digraph view1 { + graph [compound=true] + test2 [label="Test 2" fillcolor=aqua style=filled] + test3 [label="Test 3" fillcolor=aqua style=filled] + test3 -> test3 +} diff --git a/test/08_node_realations/view1.svg b/test/08_node_realations/view1.svg new file mode 100644 index 0000000..88a3429 --- /dev/null +++ b/test/08_node_realations/view1.svg @@ -0,0 +1,31 @@ + + + + + + +view1 + + + +test2 + +Test 2 + + + +test3 + +Test 3 + + + +test3->test3 + + + + + diff --git a/test/08_node_realations/view2.gv b/test/08_node_realations/view2.gv new file mode 100644 index 0000000..0cf0732 --- /dev/null +++ b/test/08_node_realations/view2.gv @@ -0,0 +1,8 @@ +digraph view2 { + graph [compound=true] + subgraph test1 { + graph [cluster=true label="Test 1"] + "test1.test3" [label="Test 3" fillcolor=aqua style=filled] + } + "test1.test3" -> "test1.test3" +} diff --git a/test/08_node_realations/view2.svg b/test/08_node_realations/view2.svg new file mode 100644 index 0000000..cdb591d --- /dev/null +++ b/test/08_node_realations/view2.svg @@ -0,0 +1,30 @@ + + + + + + +view2 + + +test1 + +Test 1 + + + +test1.test3 + +Test 3 + + + +test1.test3->test1.test3 + + + + + diff --git a/test/08_node_realations/view3.gv b/test/08_node_realations/view3.gv new file mode 100644 index 0000000..24a841b --- /dev/null +++ b/test/08_node_realations/view3.gv @@ -0,0 +1,5 @@ +digraph view3 { + graph [compound=true] + test1 [label="Test 1"] + test2 [label="Test 2" fillcolor=aqua style=filled] +} diff --git a/test/08_node_realations/view3.svg b/test/08_node_realations/view3.svg new file mode 100644 index 0000000..1a5788b --- /dev/null +++ b/test/08_node_realations/view3.svg @@ -0,0 +1,25 @@ + + + + + + +view3 + + + +test1 + +Test 1 + + + +test2 + +Test 2 + + + diff --git a/test/09_tags/input.yaml b/test/09_tags/input.yaml new file mode 100644 index 0000000..2e1e75e --- /dev/null +++ b/test/09_tags/input.yaml @@ -0,0 +1,12 @@ +nodes: + - id: ["Test 1", test1] + - id: ["Test 2", test2] + tags: ["test2_tag"] +edges: + - link: [test1, test1] + - link: [test2, test2] +views: + - id: view1 + tags: ["test2_tag"] + - id: view2 + tags: ["default"] diff --git a/test/09_tags/view1.gv b/test/09_tags/view1.gv new file mode 100644 index 0000000..99def91 --- /dev/null +++ b/test/09_tags/view1.gv @@ -0,0 +1,5 @@ +digraph view1 { + graph [compound=true] + test2 [label="Test 2"] + test2 -> test2 +} diff --git a/test/09_tags/view1.svg b/test/09_tags/view1.svg new file mode 100644 index 0000000..bd0849f --- /dev/null +++ b/test/09_tags/view1.svg @@ -0,0 +1,25 @@ + + + + + + +view1 + + + +test2 + +Test 2 + + + +test2->test2 + + + + + diff --git a/test/09_tags/view2.gv b/test/09_tags/view2.gv new file mode 100644 index 0000000..04ef6be --- /dev/null +++ b/test/09_tags/view2.gv @@ -0,0 +1,5 @@ +digraph view2 { + graph [compound=true] + test1 [label="Test 1"] + test1 -> test1 +} diff --git a/test/09_tags/view2.svg b/test/09_tags/view2.svg new file mode 100644 index 0000000..a8f1526 --- /dev/null +++ b/test/09_tags/view2.svg @@ -0,0 +1,25 @@ + + + + + + +view2 + + + +test1 + +Test 1 + + + +test1->test1 + + + + + diff --git a/test/10_minimal/default.gv b/test/10_minimal/default.gv new file mode 100644 index 0000000..56c1d34 --- /dev/null +++ b/test/10_minimal/default.gv @@ -0,0 +1,4 @@ +digraph default { + graph [compound=true] + test1 [label="Test 1"] +} diff --git a/test/10_minimal/default.svg b/test/10_minimal/default.svg new file mode 100644 index 0000000..b0212ab --- /dev/null +++ b/test/10_minimal/default.svg @@ -0,0 +1,19 @@ + + + + + + +default + + + +test1 + +Test 1 + + + diff --git a/test/10_minimal/input.yaml b/test/10_minimal/input.yaml new file mode 100644 index 0000000..543466a --- /dev/null +++ b/test/10_minimal/input.yaml @@ -0,0 +1,2 @@ +nodes: + - id: ["Test 1", test1] diff --git a/test/11_neighbors/input.yaml b/test/11_neighbors/input.yaml new file mode 100644 index 0000000..2999dd7 --- /dev/null +++ b/test/11_neighbors/input.yaml @@ -0,0 +1,28 @@ +nodes: + - id: ["Test 1", test1] + - id: ["Test 2", test2] + - id: ["Test 3", test3] + scope: test2 + +edges: + - link: [test1, test3] + +views: + - id: view1 + nodes: [test1] + # nodes must be specified explicitly + # neighbours: explicit + + - id: view2 + nodes: [test1] + # add connected nodes + neighbours: direct + + - id: view3 + nodes: [test1] + # add top most parents of connected nodes + neighbours: parent + + - id: view4 + # all three together + tags: ["default"] diff --git a/test/11_neighbors/view1.gv b/test/11_neighbors/view1.gv new file mode 100644 index 0000000..807f25d --- /dev/null +++ b/test/11_neighbors/view1.gv @@ -0,0 +1,4 @@ +digraph view1 { + graph [compound=true] + test1 [label="Test 1"] +} diff --git a/test/11_neighbors/view1.svg b/test/11_neighbors/view1.svg new file mode 100644 index 0000000..609b7b7 --- /dev/null +++ b/test/11_neighbors/view1.svg @@ -0,0 +1,19 @@ + + + + + + +view1 + + + +test1 + +Test 1 + + + diff --git a/test/11_neighbors/view2.gv b/test/11_neighbors/view2.gv new file mode 100644 index 0000000..e3e83ab --- /dev/null +++ b/test/11_neighbors/view2.gv @@ -0,0 +1,6 @@ +digraph view2 { + graph [compound=true] + test1 [label="Test 1"] + test3 [label="Test 3"] + test1 -> test3 +} diff --git a/test/11_neighbors/view2.svg b/test/11_neighbors/view2.svg new file mode 100644 index 0000000..b4fe0aa --- /dev/null +++ b/test/11_neighbors/view2.svg @@ -0,0 +1,31 @@ + + + + + + +view2 + + + +test1 + +Test 1 + + + +test3 + +Test 3 + + + +test1->test3 + + + + + diff --git a/test/11_neighbors/view3.gv b/test/11_neighbors/view3.gv new file mode 100644 index 0000000..19777cd --- /dev/null +++ b/test/11_neighbors/view3.gv @@ -0,0 +1,6 @@ +digraph view3 { + graph [compound=true] + test2 [label="Test 2"] + test1 [label="Test 1"] + test1 -> test2 +} diff --git a/test/11_neighbors/view3.svg b/test/11_neighbors/view3.svg new file mode 100644 index 0000000..45bab13 --- /dev/null +++ b/test/11_neighbors/view3.svg @@ -0,0 +1,31 @@ + + + + + + +view3 + + + +test2 + +Test 2 + + + +test1 + +Test 1 + + + +test1->test2 + + + + + diff --git a/test/11_neighbors/view4.gv b/test/11_neighbors/view4.gv new file mode 100644 index 0000000..2fe667b --- /dev/null +++ b/test/11_neighbors/view4.gv @@ -0,0 +1,9 @@ +digraph view4 { + graph [compound=true] + subgraph test2 { + graph [cluster=true label="Test 2"] + "test2.test3" [label="Test 3"] + } + test1 [label="Test 1"] + test1 -> "test2.test3" +} diff --git a/test/11_neighbors/view4.svg b/test/11_neighbors/view4.svg new file mode 100644 index 0000000..0a460f8 --- /dev/null +++ b/test/11_neighbors/view4.svg @@ -0,0 +1,36 @@ + + + + + + +view4 + + +test2 + +Test 2 + + + +test2.test3 + +Test 3 + + + +test1 + +Test 1 + + + +test1->test2.test3 + + + + + diff --git a/test/12_view_style/input.yaml b/test/12_view_style/input.yaml new file mode 100644 index 0000000..9038673 --- /dev/null +++ b/test/12_view_style/input.yaml @@ -0,0 +1,25 @@ +nodes: + - id: ["Test 1", test1] + +edges: + - link: [test1, test1] + +views: + - id: style + nodes: [] + graphviz: + graph: + style: filled + bgcolor: coral + node: + fontsize: "24" + fontname: times + edge: + dir: both + + - id: styled + nodes: [test1] + style: style + + - id: plain + nodes: [test1] diff --git a/test/12_view_style/plain.gv b/test/12_view_style/plain.gv new file mode 100644 index 0000000..4943d2a --- /dev/null +++ b/test/12_view_style/plain.gv @@ -0,0 +1,5 @@ +digraph plain { + graph [compound=true] + test1 [label="Test 1"] + test1 -> test1 +} diff --git a/test/12_view_style/plain.svg b/test/12_view_style/plain.svg new file mode 100644 index 0000000..4a9067d --- /dev/null +++ b/test/12_view_style/plain.svg @@ -0,0 +1,25 @@ + + + + + + +plain + + + +test1 + +Test 1 + + + +test1->test1 + + + + + diff --git a/test/12_view_style/styled.gv b/test/12_view_style/styled.gv new file mode 100644 index 0000000..49dbc6b --- /dev/null +++ b/test/12_view_style/styled.gv @@ -0,0 +1,8 @@ +digraph styled { + graph [compound=true] + graph [bgcolor=coral style=filled] + node [fontname=times fontsize=24] + edge [dir=both] + test1 [label="Test 1"] + test1 -> test1 +} diff --git a/test/12_view_style/styled.svg b/test/12_view_style/styled.svg new file mode 100644 index 0000000..e831010 --- /dev/null +++ b/test/12_view_style/styled.svg @@ -0,0 +1,26 @@ + + + + + + +styled + + + +test1 + +Test 1 + + + +test1->test1 + + + + + + diff --git a/test/13_edge_labels/input.yaml b/test/13_edge_labels/input.yaml new file mode 100644 index 0000000..a9defe5 --- /dev/null +++ b/test/13_edge_labels/input.yaml @@ -0,0 +1,17 @@ +nodes: + - id: ["Test 1", test1] + - id: ["Test 2", test2] + +edges: + - link: [test1, test1] + label: 'test1_edge' + + - link: [test2, test2] + label: ['tail', 'middle', 'head'] + +views: + - id: view1 + nodes: [test1] + + - id: view2 + nodes: [test2] diff --git a/test/13_edge_labels/view1.gv b/test/13_edge_labels/view1.gv new file mode 100644 index 0000000..2136542 --- /dev/null +++ b/test/13_edge_labels/view1.gv @@ -0,0 +1,5 @@ +digraph view1 { + graph [compound=true] + test1 [label="Test 1"] + test1 -> test1 [label=test1_edge] +} diff --git a/test/13_edge_labels/view1.svg b/test/13_edge_labels/view1.svg new file mode 100644 index 0000000..d1446cc --- /dev/null +++ b/test/13_edge_labels/view1.svg @@ -0,0 +1,26 @@ + + + + + + +view1 + + + +test1 + +Test 1 + + + +test1->test1 + + +test1_edge + + + diff --git a/test/13_edge_labels/view2.gv b/test/13_edge_labels/view2.gv new file mode 100644 index 0000000..16cd7c7 --- /dev/null +++ b/test/13_edge_labels/view2.gv @@ -0,0 +1,5 @@ +digraph view2 { + graph [compound=true] + test2 [label="Test 2"] + test2 -> test2 [label=middle headlabel=head taillabel=tail] +} diff --git a/test/13_edge_labels/view2.svg b/test/13_edge_labels/view2.svg new file mode 100644 index 0000000..925c864 --- /dev/null +++ b/test/13_edge_labels/view2.svg @@ -0,0 +1,28 @@ + + + + + + +view2 + + + +test2 + +Test 2 + + + +test2->test2 + + +middle +head +tail + + + diff --git a/test/14_edge_style/input.yaml b/test/14_edge_style/input.yaml new file mode 100644 index 0000000..3ba60f6 --- /dev/null +++ b/test/14_edge_style/input.yaml @@ -0,0 +1,28 @@ +nodes: + - id: ["Test 1", test1] + - id: ["Test 2", test2] + - id: ["Test 3", test3] + +edges: + - link: [test1, test1] + label: 'test1' + graphviz: + color: red + + - link: [test2, test2, edge2] + style: [test1, test1] + graphviz: + dir: both + + - link: [test3, test3] + style: edge2 + graphviz: + color: blue + +views: + - id: view1 + nodes: [test1] + - id: view2 + nodes: [test2] + - id: view3 + nodes: [test3] diff --git a/test/14_edge_style/view1.gv b/test/14_edge_style/view1.gv new file mode 100644 index 0000000..02d3ca7 --- /dev/null +++ b/test/14_edge_style/view1.gv @@ -0,0 +1,5 @@ +digraph view1 { + graph [compound=true] + test1 [label="Test 1"] + test1 -> test1 [label=test1 color=red] +} diff --git a/test/14_edge_style/view1.svg b/test/14_edge_style/view1.svg new file mode 100644 index 0000000..13db74c --- /dev/null +++ b/test/14_edge_style/view1.svg @@ -0,0 +1,26 @@ + + + + + + +view1 + + + +test1 + +Test 1 + + + +test1->test1 + + +test1 + + + diff --git a/test/14_edge_style/view2.gv b/test/14_edge_style/view2.gv new file mode 100644 index 0000000..aff7404 --- /dev/null +++ b/test/14_edge_style/view2.gv @@ -0,0 +1,5 @@ +digraph view2 { + graph [compound=true] + test2 [label="Test 2"] + test2 -> test2 [label=test1 color=red dir=both] +} diff --git a/test/14_edge_style/view2.svg b/test/14_edge_style/view2.svg new file mode 100644 index 0000000..873f53c --- /dev/null +++ b/test/14_edge_style/view2.svg @@ -0,0 +1,27 @@ + + + + + + +view2 + + + +test2 + +Test 2 + + + +test2->test2 + + + +test1 + + + diff --git a/test/14_edge_style/view3.gv b/test/14_edge_style/view3.gv new file mode 100644 index 0000000..8aa617e --- /dev/null +++ b/test/14_edge_style/view3.gv @@ -0,0 +1,5 @@ +digraph view3 { + graph [compound=true] + test3 [label="Test 3"] + test3 -> test3 [label=test1 color=blue dir=both] +} diff --git a/test/14_edge_style/view3.svg b/test/14_edge_style/view3.svg new file mode 100644 index 0000000..72b675b --- /dev/null +++ b/test/14_edge_style/view3.svg @@ -0,0 +1,27 @@ + + + + + + +view3 + + + +test3 + +Test 3 + + + +test3->test3 + + + +test1 + + + diff --git a/test/15_formatted_labels/input.yaml b/test/15_formatted_labels/input.yaml new file mode 100644 index 0000000..4891fe7 --- /dev/null +++ b/test/15_formatted_labels/input.yaml @@ -0,0 +1,21 @@ +nodes: + - id: ["Test 1", test1] + # https://www.svgrepo.com/svg/479843/duck-toy-illustration-3 + graphviz: + node_label_format: '<
{label}
>' + scope_label_format: '<
Scope: {label}
>' + - id: ["Test 2", test2] + scope: test1 + - id: ["Test 3", test3] + tags: [] + graphviz: + node_label_format: '<
{label}
>' + - id: ["Test 4", test4] + style: test3 +views: + - id: view1 + nodes: [test1] + - id: view2 + nodes: [test1, test2] + - id: view3 + nodes: [test4] diff --git a/test/15_formatted_labels/view1.gv b/test/15_formatted_labels/view1.gv new file mode 100644 index 0000000..faf8b57 --- /dev/null +++ b/test/15_formatted_labels/view1.gv @@ -0,0 +1,4 @@ +digraph view1 { + graph [compound=true] + test1 [label=<
Test 1
>] +} diff --git a/test/15_formatted_labels/view1.png b/test/15_formatted_labels/view1.png new file mode 100644 index 0000000000000000000000000000000000000000..904bb38cb88f1359bd4c3bf8a0ab2acbf41262b5 GIT binary patch literal 11351 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 literal 0 HcmV?d00001 diff --git a/test/15_formatted_labels/view2.gv b/test/15_formatted_labels/view2.gv new file mode 100644 index 0000000..7516660 --- /dev/null +++ b/test/15_formatted_labels/view2.gv @@ -0,0 +1,7 @@ +digraph view2 { + graph [compound=true] + subgraph test1 { + graph [cluster=true label=<
Scope: Test 1
>] + "test1.test2" [label="Test 2"] + } +} diff --git a/test/15_formatted_labels/view2.png b/test/15_formatted_labels/view2.png new file mode 100644 index 0000000000000000000000000000000000000000..9279a4a82923f8f96a38a525f445f1a22bcfa86d GIT binary patch literal 11390 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 QfnpR%Q8|%9;rBlO1@xhoRsaA1 literal 0 HcmV?d00001 diff --git a/test/15_formatted_labels/view3.gv b/test/15_formatted_labels/view3.gv new file mode 100644 index 0000000..c19420c --- /dev/null +++ b/test/15_formatted_labels/view3.gv @@ -0,0 +1,4 @@ +digraph view3 { + graph [compound=true] + test4 [label=<
Test 4
>] +} diff --git a/test/15_formatted_labels/view3.png b/test/15_formatted_labels/view3.png new file mode 100644 index 0000000000000000000000000000000000000000..6ec40b1a1755c7d3269c110f2e34b4909035a086 GIT binary patch literal 10725 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 F{|Bm?J&XVV literal 0 HcmV?d00001