Skip to content

Commit

Permalink
export/dot: basic export to Graphviz/DOT
Browse files Browse the repository at this point in the history
  • Loading branch information
stanislaw committed Jun 24, 2023
1 parent 0aa42a2 commit 348b598
Show file tree
Hide file tree
Showing 21 changed files with 398 additions and 3 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci-linux-ubuntu-latest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Install APT packages
run: sudo apt install -y graphviz

- name: Upgrade pip
run: |
python -m pip install --upgrade pip
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci-mac.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Install Brew packages
run: brew install graphviz

- name: Upgrade pip
run: |
python -m pip install --upgrade pip
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/periodic-integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
with:
python-version: '3.10'

- name: Install APT packages
run: sudo apt install -y graphviz

- name: Upgrade pip
run: |
python -m pip install --upgrade pip
Expand All @@ -41,6 +44,9 @@ jobs:
with:
python-version: '3.10'

- name: Install APT packages
run: sudo apt install -y graphviz

- name: Upgrade pip
run: |
python -m pip install --upgrade pip
Expand All @@ -65,6 +71,9 @@ jobs:
with:
python-version: '3.10'

- name: Install Brew packages
run: brew install graphviz

- name: Upgrade pip
run: |
python -m pip install --upgrade pip
Expand All @@ -88,6 +97,9 @@ jobs:
with:
python-version: '3.10'

- name: Install Brew packages
run: brew install graphviz

- name: Upgrade pip
run: |
python -m pip install --upgrade pip
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/periodic-release-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:
with:
python-version: '3.10'

- name: Install APT packages
run: sudo apt install -y graphviz

- name: Upgrade pip
run: |
python -m pip install --upgrade pip
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ classifiers = [
dependencies = [
"textx >= 3.0.0, == 3.*",
"jinja2 >= 2.11.2",

"graphviz >= 0.20.1",
# Reading project config from strictdoc.toml file.
"toml",

Expand Down
1 change: 1 addition & 0 deletions strictdoc/backend/sdoc/models/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def __init__( # pylint: disable=too-many-arguments
self.ng_needs_generation = False
self.meta: Optional[DocumentMeta] = None
self.node_id = uuid.uuid4().hex
self.reserved_uid = "DOCUMENT"

def assign_meta(self, meta):
assert isinstance(meta, DocumentMeta)
Expand Down
2 changes: 1 addition & 1 deletion strictdoc/cli/command_parser_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from strictdoc.backend.reqif.sdoc_reqif_fields import ReqIFProfile
from strictdoc.cli.argument_int_range import IntRange

EXPORT_FORMATS = ["html", "rst", "excel", "reqif-sdoc"]
EXPORT_FORMATS = ["html", "rst", "excel", "reqif-sdoc", "dot"]
EXCEL_PARSERS = ["basic"]


Expand Down
13 changes: 13 additions & 0 deletions strictdoc/core/actions/export_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from strictdoc.core.project_config import ProjectConfig
from strictdoc.core.traceability_index import TraceabilityIndex
from strictdoc.core.traceability_index_builder import TraceabilityIndexBuilder
from strictdoc.export.dot.document_dot_generator import DocumentDotGenerator
from strictdoc.export.html.html_generator import HTMLGenerator
from strictdoc.export.rst.document_rst_generator import DocumentRSTGenerator
from strictdoc.helpers.timing import timing_decorator
Expand Down Expand Up @@ -77,3 +78,15 @@ def export(self):
traceability_index=self.traceability_index,
output_reqif_root=output_reqif_root,
)

if "dot" in self.project_config.export_formats:
output_dot_root = os.path.join(
self.project_config.export_output_dir, "dot"
)
Path(output_dot_root).mkdir(parents=True, exist_ok=True)
DocumentDotGenerator("profile1").export_tree(
self.traceability_index, output_dot_root
)
DocumentDotGenerator("profile2").export_tree(
self.traceability_index, output_dot_root
)
16 changes: 16 additions & 0 deletions strictdoc/core/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ def get_path_to_rst_templates(self):
self.path_to_strictdoc, "strictdoc", "export", "rst", "templates"
)

def get_path_to_dot_templates(self):
if self.is_py_installer:
# If the application is run as a bundle, the PyInstaller bootloader
# extends the sys module by a flag frozen=True and sets the app
# path into variable _MEIPASS'.
bundle_dir = (
sys._MEIPASS # pylint: disable=protected-access, no-member
)
return os.path.join(bundle_dir, "templates/dot")
if self.is_nuitka:
return os.path.join(self.path_to_strictdoc, "templates/dot")
# Normal Python
return os.path.join(
self.path_to_strictdoc, "strictdoc", "export", "dot", "templates"
)

def get_path_to_html_templates(self):
if self.is_py_installer:
# If the application is run as a bundle, the PyInstaller bootloader
Expand Down
155 changes: 155 additions & 0 deletions strictdoc/export/dot/document_dot_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import os
from pathlib import Path
from typing import Union

import graphviz

from strictdoc.backend.sdoc.models.document import Document
from strictdoc.backend.sdoc.models.requirement import Requirement
from strictdoc.backend.sdoc.models.section import Section
from strictdoc.core.document_iterator import DocumentCachingIterator
from strictdoc.core.traceability_index import TraceabilityIndex
from strictdoc.export.dot.dot_templates import DotTemplates


def get_path_components(folder_path):
path = os.path.normpath(folder_path)
return path.split(os.sep)


class DocumentDotGenerator:
def __init__(self, profile: str):
assert profile in ("profile1", "profile2"), profile
self.profile: str = profile
self.index_template = DotTemplates.jinja_environment.get_template(
f"{profile}/top_level.dot"
)
self.template_section = DotTemplates.jinja_environment.get_template(
f"{profile}/section.dot"
)
self.template_requirement = DotTemplates.jinja_environment.get_template(
f"{profile}/requirement.dot"
)

def export_tree(
self, traceability_index: TraceabilityIndex, output_dot_root
):
Path(output_dot_root).mkdir(parents=True, exist_ok=True)

project_tree_content = ""

accumulated_links = []
accumulated_section_siblings = []
document_flat_requirements = []
document: Document
for document in traceability_index.document_tree.document_list:
if not document.has_any_requirements():
continue
document_content = self._print_node(
document, accumulated_links, accumulated_section_siblings
)
project_tree_content += document_content
project_tree_content += "\n\n"

this_document_flat_requirements = []

iterator = DocumentCachingIterator(document)
for node in iterator.all_content():
if isinstance(node, Requirement):
uuid = self.get_requirement_uuid(node)

this_document_flat_requirements.append(f'"value_{uuid}"')

this_document_flat_requirements_str = " -> ".join(
this_document_flat_requirements
)
document_flat_requirements.append(
this_document_flat_requirements_str
)

dot_output = self.index_template.render(
project_tree_content=project_tree_content,
accumulated_links=accumulated_links,
accumulated_section_siblings=accumulated_section_siblings,
document_flat_requirements=document_flat_requirements,
)

output_path = os.path.join(
output_dot_root, f"output.{self.profile}.dot"
)
with open(output_path, "w", encoding="utf8") as file:
file.write(dot_output)

dot = graphviz.Source.from_file(output_path)
# view=True makes the output PDF be opened in a default viewer program.
dot.render(output_path, view=False)

def _print_node(
self,
node: Union[Document, Section],
accumulated_links,
accumulated_section_siblings,
) -> str:
def get_uuid(node_):
return node_.node_id

def get_upper_sibling_section(node_: Section):
parent: Union[Document, Section] = node_.parent
node_index = parent.section_contents.index(node_)
if node_index > 0:
candidate_upper_sibling = parent.section_contents[
node_index - 1
]
if isinstance(candidate_upper_sibling, Section):
return candidate_upper_sibling
return None

node_content = ""
for subnode in node.section_contents:
if isinstance(subnode, Section):
node_content += self._print_node(
subnode, accumulated_links, accumulated_section_siblings
)
node_content += "\n"

upper_sibling_section = get_upper_sibling_section(subnode)
if upper_sibling_section is not None:
accumulated_section_siblings.append(
(get_uuid(subnode), get_uuid(upper_sibling_section))
)
elif isinstance(subnode, Requirement):
node_content += self._print_requirement_fields(
subnode, accumulated_links
)
node_content += "\n"
output = self.template_section.render(
section=node,
uuid=get_uuid(node),
node_content=node_content,
font_size=32 - node.ng_level * 4,
context_title=f"{node.context.title_number_string} {node.title}"
if isinstance(node, Section)
else node.title,
)
return output

def _print_requirement_fields(
self, requirement: Requirement, accumulated_links
):
uuid = self.get_requirement_uuid(requirement)
for parent_uid in requirement.get_parent_requirement_reference_uids():
accumulated_links.append((uuid, parent_uid))
output = self.template_requirement.render(
requirement=requirement,
uuid=uuid,
)
return output

@staticmethod
def get_requirement_uuid(requirement: Requirement):
assert isinstance(requirement, Requirement)
return (
requirement.reserved_uid
if requirement.reserved_uid is not None
else requirement.node_id
)
15 changes: 15 additions & 0 deletions strictdoc/export/dot/dot_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from jinja2 import Environment, FileSystemLoader, StrictUndefined

from strictdoc import environment


class DotTemplates:
jinja_environment = Environment(
loader=FileSystemLoader(environment.get_path_to_dot_templates()),
undefined=StrictUndefined,
)
# TODO: Check if this line is still needed (might be some older workaround).
jinja_environment.globals.update(isinstance=isinstance)
jinja_environment.trim_blocks = False
jinja_environment.lstrip_blocks = False
jinja_environment.strip_trailing_newlines = False
12 changes: 12 additions & 0 deletions strictdoc/export/dot/templates/profile1/requirement.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Requirement node
node [fontsize=16, fontname="Arial"];

// \l makes the label text be left-aligned.
// Without fontsize=16 fontname="Arial" defined below, the font gets corrupted,
// when a node has edges connected to it.
"value_{{uuid}}" [
label = "{{requirement.reserved_uid}} {{requirement.reserved_title}}\l"
width=7.0 height=1.0 fontsize=16 fontname="Arial"
penwidth=1
style="rounded"
];
17 changes: 17 additions & 0 deletions strictdoc/export/dot/templates/profile1/section.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Section node: {{section.reserved_uid}} {{section.title}}
subgraph "cluster_{{uuid}}" {
margin = "24";
label = "{{context_title}}";
fontsize = {{font_size}};
fontname="Arial-bold";
penwidth=1;
style="rounded";

labeljust="l";


{% filter indent(width=2) %}
{{node_content}}
{% endfilter %}

}
34 changes: 34 additions & 0 deletions strictdoc/export/dot/templates/profile1/top_level.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
digraph {
// layout=fdp;
// rankdir=LR;
node [shape=box];

// If true, allow edges between clusters
compound=true;
// ordering=out;
// outputorder=nodesfirst;
// newrank = true;

// Make the links have orthogonal shapes. The best approach so far to make the
// nodes be connected with the shortest edges.
// SO: How can I direct dot to use a shorter edge path?
// https://stackoverflow.com/q/24107451/598057
splines=ortho;

// Ensure some space between documents.
graph [ranksep=1];

{% filter indent(width=2) %}
{{project_tree_content}}
{% endfilter %}

{% for accumulated_link in accumulated_links -%}
"value_{{accumulated_link[0]}}"->"value_{{accumulated_link[1]}}" [
style="dotted" color="#000000", penwidth = 2, constraint=false
];
{%- endfor %}

{% for single_document_flat_requirements in document_flat_requirements -%}
{{single_document_flat_requirements}} [style=invis];
{%- endfor %}
}
12 changes: 12 additions & 0 deletions strictdoc/export/dot/templates/profile2/requirement.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Requirement node
node [fontsize=16, fontname="Arial"];

// \l makes the label text be left-aligned.
// Without fontsize=16 fontname="Arial" defined below, the font gets corrupted,
// when a node has edges connected to it.
"value_{{uuid}}" [
label = "{{requirement.reserved_title}}\l"
width=7.0 height=1.0 fontsize=16 fontname="Arial"
penwidth=1
style="rounded"
];
Loading

0 comments on commit 348b598

Please sign in to comment.