diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 0000000..2fd7eb4 --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,34 @@ +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 + + jammy_static: + runs-on: ubuntu-22.04 + + steps: + - name: main + uses: actions/checkout@v3 + with: + path: "hiearch" + - name: ccws + uses: actions/checkout@v3 + with: + repository: "asherikov/ccws" + path: "ccws" + - run: mkdir -p ccws/src + - run: mv hiearch ccws/src + - run: cd ccws; make bp_common_install_build BUILD_PROFILE=static_checks + - run: cd ccws; make bp_static_checks_install_build_python BUILD_PROFILE=static_checks + - run: cd ccws; make bp_static_checks_build_python BUILD_PROFILE=static_checks 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..263d0ba --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +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 $@..." + mkdir -p ${BUILD_DIR}/$@ + cp ${TEST_DIR}/$@/icon*.svg ${BUILD_DIR}/$@/ || true + cd ${TEST_DIR}/$@/; ${TEST_NOT} hiearch -f ${FORMAT} -o ${BUILD_DIR}/$@ *.yaml + # 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 + +svg2png: + find ${DIR} -iname "*.svg" | sed -e "s/\.svg$$//" | xargs -I {} sh -c "rsvg-convert {}.svg --format=png --output={}.png" + +.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..7aaa168 --- /dev/null +++ b/README.md @@ -0,0 +1,531 @@ +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 similar to . In other words, `graphviz` +generates multiple diagrams (views) from a single description, where each node +is a hierarchy of nodes, that is automatically expanded, collapsed, or hidden, +depending on configuration of each particular view. 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 parses 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 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] + # 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: [] + substitutions: + suffix: '!' + graphviz: + node_label_format: '<
{label}{suffix}
>' + - 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. Also, the included images must be present +in the output directory. + + + + + + + +
+ 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..f446110 --- /dev/null +++ b/hiearch/graphviz.py @@ -0,0 +1,111 @@ +import copy +import os +# https://graphviz.readthedocs.io/en/stable/api.html +import graphviz + +from . import hh_node + + +def get_node_attributes(node): + attrs = copy.deepcopy(node['graphviz']) + + attrs['label'] = node['graphviz']['node_label_format'].format(**hh_node.get_substitutions(node)) + attrs.pop('node_label_format') + attrs.pop('scope_label_format') + return attrs + + +def get_edge_attributes(edge): + attrs = copy.deepcopy(edge['graphviz']) + + for attr, label, fmt in zip(['taillabel', 'label', 'headlabel'], edge['label'], attrs['label_format']): + substitutions = edge['substitutions'] | { + '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'] + } + + formatted_label = fmt.format(**substitutions) + if len(formatted_label) > 0: + attrs[attr] = formatted_label + + if 'label_format' in attrs: + attrs.pop('label_format') + + return attrs + + +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'], **get_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): + 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 edge_out in view['scopes'][scope]: + # https://stackoverflow.com/questions/59825/how-to-retrieve-an-element-from-a-set-without-removing-it + break + 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 edge_in in view['scopes'][scope]: + # https://stackoverflow.com/questions/59825/how-to-retrieve-an-element-from-a-set-without-removing-it + break + 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, **get_edge_attributes(edge)) + + 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..f7dee30 --- /dev/null +++ b/hiearch/hh_edge.py @@ -0,0 +1,70 @@ +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]}' + return style + + +def parse(yaml_edges, edges, must_exist_nodes): + default = { + 'link': ['', ''], + 'style': None, + 'graphviz': {}, + 'label': [], + 'substitutions': {}, + # 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}') + + 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 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 'label_format' in edge['graphviz']: + if isinstance(edge['graphviz']['label_format'], str): + edge['graphviz']['label_format'] = ['{label}', edge['graphviz']['label_format'], '{label}'] + if len(edge['label']) == 0: + edge['label'] = ['', '', ''] + else: + edge['graphviz']['label_format'] = ['{label}', '{label}', '{label}'] diff --git a/hiearch/hh_node.py b/hiearch/hh_node.py new file mode 100644 index 0000000..6068f11 --- /dev/null +++ b/hiearch/hh_node.py @@ -0,0 +1,183 @@ +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 len(original_scope) != len(node['scope']): + raise RuntimeError(f'Duplicate scopes: {node["label"]} | scopes: [{original_scope}] | [{node["scope"]}]') + 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): + 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(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 in nonleaf: + continue + + 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'], + 'substitutions': {}, + # 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}') + + 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_substitutions(node): + substitutions = node['substitutions'] | { + 'label': node['label'], + 'id': node['id'], + 'scope': node['scope'], + 'style': node['style'] + } + return substitutions + + +def get_formatted_scope_label(node): + return node['graphviz']['scope_label_format'].format(**get_substitutions(node)) + + +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..70d2f71 --- /dev/null +++ b/hiearch/hh_view.py @@ -0,0 +1,158 @@ +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: dict = { + 'id': 'default', + 'nodes': None, + 'neighbours': Neighbours.EXPLICIT, + 'graphviz': {}, + 'style': None, + 'tags': [], + # overriden + 'edges': [], + 'custom_edges': [], + 'tree': {}, +} + + +opposite = { + 'in': 'out', + 'out': 'in', +} + + +def select_explicit(view, nodes, edges): + 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]) + + +def select_direct(view, nodes, edges, add_nodes): + 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]) + + +def select_parent(view, nodes, edges, add_nodes): + 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 in add_nodes: # same node can be reached in multiple ways + continue + + 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 + + +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"]} | nodes: {view["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']: + select_explicit(view, nodes, edges) + + elif Neighbours.DIRECT == view['neighbours']: + select_direct(view, nodes, edges, add_nodes) + + elif Neighbours.PARENT == view['neighbours']: + select_parent(view, nodes, edges, add_nodes) + + 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}') + + 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..522bea6 --- /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, 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, views = parse(args.inputs) + + for view in views.values(): + if len(view['nodes']) > 0: + graphviz.generate(args.output, args.format, view, nodes) + + + +if __name__ == "__main__": + main() diff --git a/hiearch/util.py b/hiearch/util.py new file mode 100644 index 0000000..79589b3 --- /dev/null +++ b/hiearch/util.py @@ -0,0 +1,46 @@ +import copy + + +def merge_styles(secondary, primary): + if 'graphviz' in secondary: + if 'substitutions' in secondary: + if 'substitutions' in primary: + primary['substitutions'] = secondary['substitutions'] | primary['substitutions'] + else: + primary['substitutions'] = secondary['substitutions'] + + 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: + 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/icon_test1.svg b/test/15_formatted_labels/icon_test1.svg new file mode 100644 index 0000000..f8f0bff --- /dev/null +++ b/test/15_formatted_labels/icon_test1.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/test/15_formatted_labels/icon_test3.svg b/test/15_formatted_labels/icon_test3.svg new file mode 100644 index 0000000..fa6c9fe --- /dev/null +++ b/test/15_formatted_labels/icon_test3.svg @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/test/15_formatted_labels/input.yaml b/test/15_formatted_labels/input.yaml new file mode 100644 index 0000000..0656bfd --- /dev/null +++ b/test/15_formatted_labels/input.yaml @@ -0,0 +1,30 @@ +nodes: + - id: ["Test 1", test1] + # 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: [] + substitutions: + suffix: '!' # substitute `{suffix}` for `!` in labels + graphviz: + node_label_format: '<
{label}{suffix}
>' + - id: ["Test 4", test4] + style: test3 +edges: + - link: [test4, test4] + substitutions: + tilde: '~' + graphviz: + label_format: ['', '', '{tilde}{node_in}{tilde}'] +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 0000000..904bb38 Binary files /dev/null and b/test/15_formatted_labels/view1.png differ 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 0000000..9279a4a Binary files /dev/null and b/test/15_formatted_labels/view2.png differ diff --git a/test/15_formatted_labels/view3.gv b/test/15_formatted_labels/view3.gv new file mode 100644 index 0000000..a4664bf --- /dev/null +++ b/test/15_formatted_labels/view3.gv @@ -0,0 +1,5 @@ +digraph view3 { + graph [compound=true] + test4 [label=<
Test 4!
>] + test4 -> test4 [headlabel="~test4~"] +} diff --git a/test/15_formatted_labels/view3.png b/test/15_formatted_labels/view3.png new file mode 100644 index 0000000..5a19bd8 Binary files /dev/null and b/test/15_formatted_labels/view3.png differ