Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

export/dot: basic export to Graphviz/DOT #1201

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 1 addition & 1 deletion strictdoc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from strictdoc.core.environment import SDocRuntimeEnvironment

__version__ = "0.0.43a1"
__version__ = "0.0.43a2"


environment = SDocRuntimeEnvironment(__file__)
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
15 changes: 14 additions & 1 deletion 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 @@ -39,7 +40,7 @@ def build_index(self):
sys.exit(1)
self.traceability_index = traceability_index

@timing_decorator("HTML export")
@timing_decorator("Export SDoc")
def export(self):
assert (
self.traceability_index is not None
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
157 changes: 157 additions & 0 deletions strictdoc/export/dot/document_dot_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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)
if len(this_document_flat_requirements) > 1
else None
)
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
14 changes: 14 additions & 0 deletions strictdoc/export/dot/templates/profile1/requirement.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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"
];
34 changes: 34 additions & 0 deletions strictdoc/export/dot/templates/profile1/section.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Section node: {{section.reserved_uid}} {{section.title}}
subgraph "cluster_{{uuid}}" {
label = "{{context_title}}";
fontsize = {{font_size}};
fontname="Arial-bold";
penwidth=1;
style="rounded";

// Left align section labels.
labeljust="l";

"dummy_top_{{uuid}}" [
height=0,
width=0,
margin=0,
color="red",
fontsize=16
style=invis,
];

{% filter indent(width=2 + section.ng_level * 2) %}
{{node_content}}
{% endfilter %}

"dummy_bottom_{{uuid}}" [
height=0,
width=0,
margin=0,
color="red",
fontsize=16
style=invis,
];

}
Loading
Loading