diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6fb7da72d..89612e762 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,6 +33,12 @@ jobs: sphinx-version: "5.0" steps: - uses: actions/checkout@v4 + - name: Install graphviz (linux) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get install graphviz + - name: Install graphviz (windows) + if: matrix.os == 'windows-latest' + run: choco install --no-progress graphviz - name: Set Up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: diff --git a/docs/conf.py b/docs/conf.py index 243588b9b..707e736a6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -90,6 +90,8 @@ } } +graphviz_output_format = "svg" + # -- Options for html builder ---------------------------------------------- html_static_path = ["_static"] @@ -497,6 +499,7 @@ } """, "tutorial": """ + left to right direction skinparam backgroundcolor transparent skinparam Arrow { Color #57ACDC @@ -507,6 +510,27 @@ """, } +needs_graphviz_styles = { + "tutorial": { + "graph": { + "rankdir": "LR", + "bgcolor": "transparent", + }, + "node": { + "fontname": "sans-serif", + "fontsize": 12, + "penwidth": 2, + "margin": "0.11,0.11", + "style": "rounded", + }, + "edge": { + "color": "#57ACDC", + "fontsize": 10, + "fontcolor": "#808080", + }, + } +} + needs_show_link_type = False needs_show_link_title = False needs_title_optional = True diff --git a/docs/configuration.rst b/docs/configuration.rst index 147b8a35c..7d0f0f8c3 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -108,7 +108,7 @@ By default it is set to: * **title**: Title, used as human readable name in lists * **prefix**: A prefix for generated IDs, to easily identify that an ID belongs to a specific type. Can also be "" * **color**: A color as hex value. Used in diagrams and some days maybe in other representations as well. Can also be "" -* **style**: A plantuml node type, like node, artifact, frame, storage or database. See `plantuml documentation `_ for more. +* **style**: A plantuml node type, like node, artifact, frame, storage or database. See `plantuml documentation `__ for more. .. note:: @@ -532,6 +532,19 @@ If set to False, the filter results contains the original need fields and any ma needs_allow_unsafe_filters = True + +.. _needs_flow_engine: + +needs_flow_engine +~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.2.0 + +Select between the rendering engines for :ref:`needflow` diagrams, + +* ``plantuml``: Use `PlantUML `__ to render the diagrams (default). +* ``graphviz``: Use `Graphviz `__ to render the diagrams. + .. _needs_flow_show_links: needs_flow_show_links @@ -601,11 +614,75 @@ This configurations can then be used like this: .. needflow:: :tags: flow_example :types: spec - :config: my_config + :config: lefttoright,my_config +Multiple configurations can be used by separating them with a comma, +these will be applied in the order they are defined. See :ref:`needflow config option ` for more details and already available configurations. +.. _needs_graphviz_styles: + +needs_graphviz_styles +~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.2.0 + +This must be a dictionary which can store multiple `Graphviz configurations `__. +These configs can then be selected when using :ref:`needflow` and the engine is set to ``graphviz``. + +.. code-block:: python + + needs_graphviz_styles = { + "my_config": { + "graph": { + "rankdir": "LR", + "bgcolor": "transparent", + }, + "node": { + "fontname": "sans-serif", + "fontsize": 12, + }, + "edge": { + "color": "#57ACDC", + "fontsize": 10, + }, + } + } + +This configurations can then be used like this: + +.. code-block:: restructuredtext + + .. needflow:: + :engine: graphviz + :config: lefttoright,my_config + +Multiple configurations can be used by separating them with a comma, +these will be merged in the order they are defined. +For example ``my_config1,my_config2`` would be the same as ``my_config3``: + +.. code-block:: python + + needs_graphviz_styles = { + "my_config1": { + "graph": { + "rankdir": "LR", + } + }, + "my_config2": { + "graph": { + "bgcolor": "transparent", + } + } + "my_config3": { + "graph": { + "rankdir": "LR", + "bgcolor": "transparent", + } + } + } + .. _needs_report_template: needs_report_template @@ -719,9 +796,9 @@ needs_diagram_template ~~~~~~~~~~~~~~~~~~~~~~ This option allows to control the content of diagram elements which get automatically generated by using -`.. needflow::` / :ref:`needflow`. +`.. needflow::` / :ref:`needflow` (when using the ``plantuml`` engine). -This function is based on `plantuml `_, so that each +This function is based on `plantuml `__, so that each `supported style `_ can be used. The rendered template is used inside the following plantuml syntax and must care about leaving the final string diff --git a/docs/directives/needflow.rst b/docs/directives/needflow.rst index 14e167312..2b506153b 100644 --- a/docs/directives/needflow.rst +++ b/docs/directives/needflow.rst @@ -16,17 +16,47 @@ If you provide an argument, we use it as caption for the generated image. :tags: flow_example :link_types: tests, blocks :show_link_names: + :config: lefttoright + +.. versionadded:: 2.2.0 + + You can now also set all or individual ``needflow`` directives to use the Graphviz engine for rendering the graph, which can speed up the rendering process for large amount of graphs. + + See the :ref:`needs_flow_engine` configuration option and the :ref:`directive engine option ` for more information. + + .. dropdown:: Using Graphviz engine + + .. needflow:: My first needflow + :engine: graphviz + :filter: is_need + :tags: flow_example + :link_types: tests, blocks + :show_link_names: + :config: default,lefttoright Dependencies ------------ -``needflow`` uses `PlantUML `_ and the +plantuml +~~~~~~~~ + +``needflow``, with the default ``plantuml`` engine, uses `PlantUML `_ and the Sphinx-extension `sphinxcontrib-plantuml `_ for generating the flows. Both must be available and correctly configured to work. Please read :ref:`install plantuml ` for a step-by-step installation explanation. +graphviz +~~~~~~~~ + +``needflow``, with the ``graphviz`` engine uses the `Graphviz dot `_ executable for rendering the flowchart, +and the built-in :any:`sphinx.ext.graphviz` extension from Sphinx. + +See https://graphviz.org/download/ for how to install Graphviz, +and :any:`sphinx.ext.graphviz` for configuration options. +In particular, you may want to set the ``graphviz_output_format`` configuration option in your ``conf.py``. + Options ------- @@ -35,10 +65,29 @@ Options **needflow** supports the full filtering possibilities of **Sphinx-Needs**. Please see :ref:`filter` for more information. +.. _needflow_engine: + +engine +~~~~~~ + +.. versionadded:: 2.3.0 + +You can set the engine to use for rendering the flowchart, +to either ``plantuml`` (default) or ``graphviz``. + .. _needflow_root_id: .. _needflow_root_direction: .. _needflow_root_depth: +.. _needflow_alt: + +alt +~~~ + +.. versionadded:: 2.3.0 + +Set the ``alt`` option to a string to add an alternative text to the generated image. + root_id ~~~~~~~ @@ -77,6 +126,30 @@ Other need filters are applied on this initial selection of connected needs. :link_types: tests, blocks :show_link_names: +.. dropdown:: Using Graphviz engine + + .. needflow:: + :engine: graphviz + :root_id: spec_flow_002 + :root_direction: incoming + :link_types: tests, blocks + :show_link_names: + + .. needflow:: + :engine: graphviz + :root_id: spec_flow_002 + :root_direction: outgoing + :link_types: tests, blocks + :show_link_names: + + .. needflow:: + :engine: graphviz + :root_id: spec_flow_002 + :root_direction: outgoing + :root_depth: 1 + :link_types: tests, blocks + :show_link_names: + .. _needflow_show_filters: show_filters @@ -87,7 +160,14 @@ Adds information of used filters below generated flowchart. .. need-example:: .. needflow:: - :tags: main_example + :tags: flow_example + :show_filters: + +.. dropdown:: Using Graphviz engine + + .. needflow:: + :engine: graphviz + :tags: flow_example :show_filters: .. _needflow_show_legend: @@ -101,7 +181,14 @@ for flowcharts. .. need-example:: .. needflow:: - :tags: main_example + :tags: flow_example + :show_legend: + +.. dropdown:: Using Graphviz engine + + .. needflow:: + :engine: graphviz + :tags: flow_example :show_legend: .. _needflow_show_link_names: @@ -119,7 +206,14 @@ Setup data can be found in test case document `tests/doc_test/doc_extra_links`. .. need-example:: .. needflow:: - :tags: main_example + :tags: flow_example + :show_link_names: + +.. dropdown:: Using Graphviz engine + + .. needflow:: + :engine: graphviz + :tags: flow_example :show_link_names: .. _needflow_link_types: @@ -187,6 +281,14 @@ See also :ref:`needs_extra_links` for more details about specific link types. :link_types: tests, blocks :show_link_names: +.. dropdown:: Using Graphviz engine + + .. needflow:: + :engine: graphviz + :tags: flow_example + :link_types: tests, blocks + :show_link_names: + .. _needflow_config: config @@ -195,7 +297,10 @@ config .. versionadded:: 0.5.2 You can specify a configuration using the ``:config:`` option but you should -set the :ref:`needs_flow_configs` configuration parameter in **conf.py**. +set the :ref:`needs_flow_configs` configuration parameter in **conf.py**, +when using the ``plantuml`` engine, +or the :ref:`needs_graphviz_styles` configuration, +when using the ``graphviz`` engine. .. need-example:: @@ -219,7 +324,20 @@ You can apply multiple configurations together by separating them via ``,`` symb :show_link_names: :config: monochrome,lefttoright,handwritten -**Sphinx-Needs** provides some necessary configurations already. They are: +.. dropdown:: Using Graphviz engine + + .. needflow:: + :engine: graphviz + :filter: is_need + :tags: flow_example + :types: spec + :link_types: tests, blocks + :show_link_names: + :config: default,lefttoright + +**Sphinx-Needs** provides some necessary configurations already. + +For ``needs_flow_configs`` they are: .. list-table:: :header-rows: 1 @@ -244,6 +362,23 @@ You can apply multiple configurations together by separating them via ``,`` symb - * cplant * Cplant theme. Read `this `_ for example. +For ``needs_graphviz_styles`` they are: + +.. list-table:: + :header-rows: 1 + :widths: 30,70 + + - * config name + * description + - * default + * Default style used when ``config`` is not set + - * lefttoright + * Direction of boxes is left to right + - * toptobottom + * Direction of boxes is top to bottom (default value) + - * transparent + * Transparent background + .. _needflow_scale: scale @@ -282,6 +417,14 @@ sets the border for each need of the needflow to **red** if the need also passes :link_types: tests, blocks :highlight: id in ['spec_flow_002', 'subspec_2'] or type == 'req' +.. dropdown:: Using Graphviz engine + + .. needflow:: + :engine: graphviz + :tags: flow_example + :link_types: tests, blocks + :highlight: id in ['spec_flow_002', 'subspec_2'] or type == 'req' + .. _needflow_border_color: border_color @@ -302,6 +445,17 @@ The value should be written with the :ref:`variant syntax `__ JS package: :columns: id,type,title,status :style: datatable -Finally, we can display a flow diagram of the need items, to also show the relationships between them: +Finally, we can display a :ref:`flow diagram ` of the need items, to also show the relationships between them: .. need-example:: Flow diagram .. needflow:: Engineering plan to develop a car + :alt: Engineering plan to develop a car :root_id: T_CAR :config: lefttoright,tutorial :show_link_names: @@ -287,6 +290,23 @@ Finally, we can display a flow diagram of the need items, to also show the relat [status == 'in progress']:0000FF, [status == 'closed']:00FF00 +.. dropdown:: Aternative use of Graphviz engine + + You can also use the Graphviz engine to render the flow diagram, by setting the ``engine`` option to ``graphviz``: + + .. need-example:: Flow diagram with Graphviz + + .. needflow:: Engineering plan to develop a car + :engine: graphviz + :alt: Engineering plan to develop a car + :root_id: T_CAR + :config: lefttoright,tutorial + :show_link_names: + :border_color: + [status == 'open']:FF0000, + [status == 'in progress']:0000FF, + [status == 'closed']:00FF00 + Analysing Metrics ----------------- diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index 187b291c8..09fe967a0 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -7,6 +7,7 @@ from sphinx.application import Sphinx from sphinx.config import Config as _SphinxConfig +from sphinx_needs.data import GraphvizStyleType from sphinx_needs.defaults import DEFAULT_DIAGRAM_TEMPLATE if TYPE_CHECKING: @@ -376,6 +377,9 @@ def __setattr__(self, name: str, value: Any) -> None: allow_unsafe_filters: bool = field( default=False, metadata={"rebuild": "html", "types": (bool,)} ) + flow_engine: Literal["plantuml", "graphviz"] = field( + default="plantuml", metadata={"rebuild": "env", "types": (str,)} + ) flow_show_links: bool = field( default=False, metadata={"rebuild": "html", "types": (bool,)} ) @@ -401,6 +405,9 @@ def __setattr__(self, name: str, value: Any) -> None: flow_configs: dict[str, str] = field( default_factory=dict, metadata={"rebuild": "html", "types": ()} ) + graphviz_styles: dict[str, GraphvizStyleType] = field( + default_factory=dict, metadata={"rebuild": "html", "types": ()} + ) template_folder: str = field( default="needs_templates/", metadata={"rebuild": "html", "types": (str,)} ) diff --git a/sphinx_needs/data.py b/sphinx_needs/data.py index 7761f9fef..0d698c984 100644 --- a/sphinx_needs/data.py +++ b/sphinx_needs/data.py @@ -525,7 +525,7 @@ class NeedsFilteredBaseType(NeedsBaseDataType): filter_code: list[str] filter_func: None | str export_id: str - filter_warning: str + filter_warning: str | None """If set, the filter is exported with this ID in the needs.json file.""" @@ -567,9 +567,28 @@ class _NeedsFilterType(NeedsFilteredBaseType): layout: Literal["list", "table", "diagram"] +class GraphvizStyleType(TypedDict, total=False): + """Defines a graphviz style""" + + root: dict[str, str] + """Root attributes""" + graph: dict[str, str] + """Graph attributes""" + node: dict[str, str] + """Node attributes""" + edge: dict[str, str] + """Edge attributes""" + + class NeedsFlowType(NeedsFilteredDiagramBaseType): """Data for a single (filtered) flow chart.""" + classes: list[str] + """List of CSS classes.""" + + alt: str + """Alternative text for the diagram in HTML output.""" + root_id: str | None """need ID to use as a root node.""" @@ -582,6 +601,9 @@ class NeedsFlowType(NeedsFilteredDiagramBaseType): border_color: str | None """Color of the outline of the needs, specified using the variant syntax.""" + graphviz_style: GraphvizStyleType + """Graphviz style configuration.""" + class NeedsGanttType(NeedsFilteredDiagramBaseType): """Data for a single (filtered) gantt chart.""" @@ -618,7 +640,7 @@ class NeedsPieType(NeedsBaseDataType): text_color: None | str shadow: bool filter_func: None | str - filter_warning: str + filter_warning: str | None class NeedsSequenceType(NeedsFilteredDiagramBaseType): diff --git a/sphinx_needs/defaults.py b/sphinx_needs/defaults.py index 3762e0a1d..b477af838 100644 --- a/sphinx_needs/defaults.py +++ b/sphinx_needs/defaults.py @@ -1,10 +1,13 @@ from __future__ import annotations import os -from typing import Any +from typing import TYPE_CHECKING, Any from docutils.parsers.rst import directives +if TYPE_CHECKING: + from sphinx_needs.data import GraphvizStyleType + DEFAULT_DIAGRAM_TEMPLATE = """ {%- if is_need -%} {{type_name}}\\n**{{title|wordwrap(15, wrapstring='**\\\\n**')}}**\\n{{id}} @@ -204,6 +207,32 @@ """, } +GRAPHVIZ_STYLE_DEFAULTS: dict[str, GraphvizStyleType] = { + "default": { + "node": { + "margin": "0.21,0.11", + }, + "edge": { + "minlen": "2", + }, + }, + "lefttoright": { + "graph": { + "rankdir": "LR", + } + }, + "toptobottom": { + "graph": { + "rankdir": "TB", + } + }, + "transparent": { + "graph": { + "bgcolor": "transparent", + } + }, +} + TITLE_REGEX = r'([^\s]+) as "([^"]+)"' diff --git a/sphinx_needs/diagrams_common.py b/sphinx_needs/diagrams_common.py index f54f4c0e3..82ccb30ff 100644 --- a/sphinx_needs/diagrams_common.py +++ b/sphinx_needs/diagrams_common.py @@ -169,7 +169,10 @@ def get_debug_container(puml_node: nodes.Element) -> nodes.container: def calculate_link( - app: Sphinx, need_info: NeedsInfoType, _fromdocname: None | str + app: Sphinx, + need_info: NeedsInfoType, + _fromdocname: None | str, + relative: str = "..", ) -> str: """ Link calculation @@ -193,11 +196,14 @@ def calculate_link( # check if need_info["external_url"] is relative path parsed_url = urlparse(need_info["external_url"]) if not parsed_url.scheme and not os.path.isabs(need_info["external_url"]): - # only need to add ../ or ..\ to get out of the image folder - link = ".." + os.path.sep + need_info["external_url"] + link = relative + "/" + need_info["external_url"] elif _docname := need_info["docname"]: link = ( - "../" + builder.get_target_uri(_docname) + "#" + need_info["target_id"] + relative + + "/" + + builder.get_target_uri(_docname) + + "#" + + need_info["target_id"] ) if need_info["is_part"]: link = f"{link}.{need_info['id']}" diff --git a/sphinx_needs/directives/needflow/__init__.py b/sphinx_needs/directives/needflow/__init__.py new file mode 100644 index 000000000..340365364 --- /dev/null +++ b/sphinx_needs/directives/needflow/__init__.py @@ -0,0 +1,12 @@ +from ._directive import NeedflowDirective, NeedflowGraphiz, NeedflowPlantuml +from ._graphviz import html_visit_needflow_graphviz, process_needflow_graphviz +from ._plantuml import process_needflow_plantuml + +__all__ = ( + "NeedflowDirective", + "NeedflowGraphiz", + "process_needflow_graphviz", + "html_visit_needflow_graphviz", + "process_needflow_plantuml", + "NeedflowPlantuml", +) diff --git a/sphinx_needs/directives/needflow/_directive.py b/sphinx_needs/directives/needflow/_directive.py new file mode 100644 index 000000000..3c2489c75 --- /dev/null +++ b/sphinx_needs/directives/needflow/_directive.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.ext.graphviz import ( + figure_wrapper, +) + +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import ( + GraphvizStyleType, + NeedsFlowType, +) +from sphinx_needs.debug import measure_time +from sphinx_needs.filter_common import FilterBase +from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.utils import ( + add_doc, + get_scale, + split_link_types, +) + +LOGGER = get_logger(__name__) + +if TYPE_CHECKING: + from typing_extensions import Unpack + + +class NeedflowDirective(FilterBase): + """ + Directive to get flow charts. + """ + + optional_arguments = 1 # the caption + final_argument_whitespace = True + option_spec = { + "engine": lambda c: directives.choice(c, ("graphviz", "plantuml")), + # basic options + "alt": directives.unchanged, + "scale": directives.unchanged_required, + "align": lambda c: directives.choice(c, ("left", "center", "right")), + "class": directives.class_option, + "name": directives.unchanged, + # initial filtering + "root_id": directives.unchanged_required, + "root_direction": lambda c: directives.choice( + c, ("both", "incoming", "outgoing") + ), + "root_depth": directives.nonnegative_int, + "link_types": directives.unchanged_required, + # debug; render the graph code in the document + "debug": directives.flag, + # formatting + "highlight": directives.unchanged_required, + "border_color": directives.unchanged_required, + "show_legend": directives.flag, + "show_filters": directives.flag, + "show_link_names": directives.flag, + "config": directives.unchanged_required, + } + + # Update the options_spec with values defined in the FilterBase class + option_spec.update(FilterBase.base_option_spec) + + @measure_time("needflow") + def run(self) -> Sequence[nodes.Node]: + needs_config = NeedsSphinxConfig(self.env.config) + location = (self.env.docname, self.lineno) + + id = self.env.new_serialno("needflow") + targetid = f"needflow-{self.env.docname}-{id}" + + all_link_types = ",".join(x["option"] for x in needs_config.extra_links) + link_types = split_link_types( + self.options.get("link_types", all_link_types), location + ) + + engine = self.options.get("engine", needs_config.flow_engine) + assert engine in ["graphviz", "plantuml"], f"Unknown needflow engine '{engine}'" + + config_names: str = self.options.get("config", "") + config = "" + graphviz_style: GraphvizStyleType = {} + if engine == "plantuml": + _configs = [] + for config_name in config_names.split(","): + config_name = config_name.strip() + if config_name and config_name in needs_config.flow_configs: + _configs.append(needs_config.flow_configs[config_name]) + elif config_name: + log_warning( + LOGGER, + f"config key {config_name!r} not in 'need_flows_configs'", + "needflow", + location=self.get_location(), + ) + config = "\n".join(_configs) + else: + config_names = config_names if config_names else "default" + for config_name in config_names.split(","): + config_name = config_name.strip() + try: + if config_name and config_name in needs_config.graphviz_styles: + for key, value in needs_config.graphviz_styles[ + config_name + ].items(): + if key in graphviz_style: + graphviz_style[key].update(value) # type: ignore[literal-required] + else: + graphviz_style[key] = value # type: ignore[literal-required] + elif config_name: + log_warning( + LOGGER, + f"config key {config_name!r} not in 'needs_graphviz_styles'", + "needflow", + location=self.get_location(), + ) + except Exception as err: + if config_name: + log_warning( + LOGGER, + f"malformed config {config_name!r} in 'needs_graphviz_styles': {err}", + "needflow", + location=self.get_location(), + ) + + add_doc(self.env, self.env.docname) + + attributes: NeedsFlowType = { + "docname": self.env.docname, + "lineno": self.lineno, + "target_id": targetid, + "root_id": self.options.get("root_id"), + "root_direction": self.options.get("root_direction", "all"), + "root_depth": self.options.get("root_depth", None), + "show_legend": "show_legend" in self.options, + "show_filters": "show_filters" in self.options, + "show_link_names": "show_link_names" in self.options, + "link_types": link_types, + "config_names": config_names, + "config": config, + "graphviz_style": graphviz_style, + "scale": get_scale(self.options, self.get_location()), + "highlight": self.options.get("highlight", ""), + "border_color": self.options.get("border_color", None), + "align": self.options.get("align", "center"), + "debug": "debug" in self.options, + "caption": self.arguments[0] if self.arguments else None, + "classes": self.options.get("class", []), + "alt": self.options.get("alt", ""), + **self.collect_filter_attributes(), + } + + # TODO currently the engines handle captions differently + # I think plantuml should use the same "standard" approach as graphviz + + if engine == "plantuml": + pnode = NeedflowPlantuml("", **attributes) + self.set_source_info(pnode) + self.add_name(pnode) + return [nodes.target("", "", ids=[targetid]), pnode] + + elif engine == "graphviz": + gnode = NeedflowGraphiz("", **attributes) + self.set_source_info(gnode) + + if not self.arguments: + figure = nodes.figure("", gnode) + if "align" in gnode: + figure["align"] = gnode.attributes.pop("align") # type: ignore[misc] + figure["ids"] = [targetid] + self.add_name(gnode) + return [figure] + else: + figure = figure_wrapper(self, gnode, self.arguments[0]) # type: ignore[arg-type] + figure["ids"] = [targetid] + self.add_name(figure) + return [figure] + + raise ValueError(f"Unknown needflow engine '{engine}'") + + +class NeedflowPlantuml(nodes.General, nodes.Element): + if TYPE_CHECKING: + + def __init__( + self, + rawsource: str, + /, + **kwargs: Unpack[NeedsFlowType], + ) -> None: ... + + attributes: NeedsFlowType + + +class NeedflowGraphiz(nodes.General, nodes.Element): + if TYPE_CHECKING: + + def __init__( + self, + rawsource: str, + /, + **kwargs: Unpack[NeedsFlowType], + ) -> None: ... + + attributes: NeedsFlowType diff --git a/sphinx_needs/directives/needflow/_graphviz.py b/sphinx_needs/directives/needflow/_graphviz.py new file mode 100644 index 000000000..3fe223fa6 --- /dev/null +++ b/sphinx_needs/directives/needflow/_graphviz.py @@ -0,0 +1,658 @@ +from __future__ import annotations + +import html +import textwrap +from functools import lru_cache +from typing import Callable, Literal, TypedDict + +from docutils import nodes +from sphinx.application import Sphinx +from sphinx.ext.graphviz import ( + ClickableMapDefinition, + GraphvizError, + render_dot, +) +from sphinx.util.logging import getLogger + +from sphinx_needs.config import LinkOptionsType, NeedsSphinxConfig +from sphinx_needs.data import NeedsInfoType, SphinxNeedsData +from sphinx_needs.debug import measure_time +from sphinx_needs.diagrams_common import calculate_link +from sphinx_needs.directives.needflow._directive import NeedflowGraphiz +from sphinx_needs.directives.utils import no_needs_found_paragraph +from sphinx_needs.filter_common import ( + filter_single_need, + process_filters, +) +from sphinx_needs.logging import log_warning +from sphinx_needs.utils import ( + match_variants, + remove_node_from_tree, +) + +from ._shared import create_filter_paragraph, filter_by_tree, get_root_needs + +try: + from sphinx.writers.html5 import HTML5Translator +except ImportError: + from sphinx.writers.html import HTML5Translator + +LOGGER = getLogger(__name__) + + +@measure_time("needflow_graphviz") +def process_needflow_graphviz( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, + found_nodes: list[nodes.Element], +) -> None: + needs_config = NeedsSphinxConfig(app.config) + env_data = SphinxNeedsData(app.env) + all_needs = env_data.get_or_create_needs() + + link_type_names = [link["option"].upper() for link in needs_config.extra_links] + allowed_link_types_options = [link.upper() for link in needs_config.flow_link_types] + + node: NeedflowGraphiz + for node in found_nodes: # type: ignore[assignment] + attributes = node.attributes + + if not needs_config.include_needs: + remove_node_from_tree(node) + continue + + if app.builder.format != "html": + log_warning( + LOGGER, + "NeedflowGraphiz is only supported for HTML output.", + "needflow", + location=node, + ) + remove_node_from_tree(node) + continue + + if attributes["show_filters"]: + para = create_filter_paragraph(attributes) + # add the paragraph to after the surrounding figure + node.parent.parent.insert(node.parent.parent.index(node.parent) + 1, para) + + option_link_types = [link.upper() for link in attributes["link_types"]] + for lt in option_link_types: + if lt not in link_type_names: + log_warning( + LOGGER, + "Unknown link type {link_type} in needflow {flow}. Allowed values: {link_types}".format( + link_type=lt, + flow=attributes["target_id"], + link_types=",".join(link_type_names), + ), + "needflow", + None, + ) + + # compute the allowed link names + allowed_link_types: list[LinkOptionsType] = [] + for link_type in needs_config.extra_links: + # Skip link-type handling, if it is not part of a specified list of allowed link_types or + # if not part of the overall configuration of needs_flow_link_types + if ( + attributes["link_types"] + and link_type["option"].upper() not in option_link_types + ) or ( + not attributes["link_types"] + and link_type["option"].upper() not in allowed_link_types_options + ): + continue + # skip creating links from child needs to their own parent need + if link_type["option"] == "parent_needs": + continue + allowed_link_types.append(link_type) + + init_filtered_needs = ( + filter_by_tree( + all_needs, + root_id, + allowed_link_types, + attributes["root_direction"], + attributes["root_depth"], + ).values() + if (root_id := attributes["root_id"]) + else all_needs.values() + ) + filtered_needs = process_filters(app, init_filtered_needs, node.attributes) + + if not filtered_needs: + node.replace_self( + no_needs_found_paragraph(attributes.get("filter_warning")) + ) + continue + + id_comp_to_need = {need["id_complete"]: need for need in filtered_needs} + + content = "digraph needflow {\ncompound=true;\n" + + # global settings + for key, value in attributes["graphviz_style"].get("root", {}).items(): + content += f"{key}={_quote(str(value))};\n" + for etype in ("graph", "node", "edge"): + if etype in attributes["graphviz_style"]: + content += f"{etype} [\n" + for key, value in attributes["graphviz_style"][etype].items(): # type: ignore[literal-required] + content += f" {key}={_quote(str(value))};\n" + content += "]\n" + + # calculate node definitions + content += "\n// node definitions\n" + rendered_nodes: dict[str, _RenderedNode] = {} + """A mapping of node id_complete to the cluster id if the node is a subgraph, else None.""" + for root_need in get_root_needs(filtered_needs): + content += _render_node( + root_need, + node, + needs_config, + lambda n: calculate_link(app, n, fromdocname, relative="."), + id_comp_to_need, + rendered_nodes, + ) + + # calculate edge definitions + content += "\n// edge definitions\n" + for need in filtered_needs: + for link_type in allowed_link_types: + for link in need[link_type["option"]]: # type: ignore[literal-required] + content += _render_edge( + need, link, link_type, node, needs_config, rendered_nodes + ) + + if attributes["show_legend"]: + content += _create_legend( + [r["need"] for r in rendered_nodes.values()], needs_config + ) + + content += "}" + + node["resolved_content"] = content + + if attributes["debug"]: + code = nodes.literal_block( + content, content, language="dot", linenos=True, force=True + ) + code.source, code.line = node.source, node.line + # add the debug code to after the surrounding figure + node.parent.parent.insert(node.parent.parent.index(node.parent) + 1, code) + + +class _RenderedNode(TypedDict): + cluster_id: str | None + need: NeedsInfoType + + +def _quote(text: str) -> str: + """Quote a string for use in a graphviz file.""" + return '"' + text.replace('"', '\\"') + '"' + + +def _render_node( + need: NeedsInfoType, + node: NeedflowGraphiz, + config: NeedsSphinxConfig, + calc_link: Callable[[NeedsInfoType], str], + id_comp_to_need: dict[str, NeedsInfoType], + rendered_nodes: dict[str, _RenderedNode], + subgraph: bool = True, +) -> str: + """Render a node in the graphviz format.""" + + if subgraph and ( + ( + need["is_need"] + and any(f"{need['id']}.{i}" in id_comp_to_need for i in need["parts"]) + ) + or any(i in id_comp_to_need for i in need["parent_needs_back"]) + ): + # graphviz cannot nest nodes, + # so we have to create a subgraph to represent a need with parts/children + return _render_subgraph( + need, node, config, calc_link, id_comp_to_need, rendered_nodes + ) + + rendered_nodes[need["id_complete"]] = {"need": need, "cluster_id": None} + + params: list[tuple[str, str]] = [] + + # label + params.append(("label", _label(need, "left"))) + params.append(("tooltip", _quote(need["id_complete"]))) + + # link + if _link := calc_link(need): + params.extend([("href", _quote(_link)), ("target", _quote("_top"))]) + + # shape + if need["is_need"]: + if need["type_style"] not in _plantuml_shapes: + log_warning( + LOGGER, + f"Unknown node style {need['type_style']!r} for graphviz engine", + "needflow", + None, + once=True, + ) + shape = _plantuml_shapes.get(need["type_style"], need["type_style"]) + params.append(("shape", _quote(shape))) + else: + params.append(("shape", "rectangle")) + + # fill color + if need["type_color"]: + style = node.attributes["graphviz_style"].get("node", {}).get("style", "") + new_style = style + ",filled" if style else "filled" + params.append(("style", _quote(new_style))) + params.append(("fillcolor", _quote(need["type_color"]))) + + # outline color + if node["highlight"] and filter_single_need(need, config, node["highlight"]): + params.append(("color", "red")) + elif node["border_color"]: + color = match_variants( + node["border_color"], + {**need}, + config.variants, + location=node, + ) + if color: + params.append(("color", _quote("#" + color))) + + id = _quote(need["id_complete"]) + param_str = ", ".join(f"{key}={value}" for key, value in params) + return f"{id} [{param_str}];\n" + + +def _render_subgraph( + need: NeedsInfoType, + node: NeedflowGraphiz, + config: NeedsSphinxConfig, + calc_link: Callable[[NeedsInfoType], str], + id_comp_to_need: dict[str, NeedsInfoType], + rendered_nodes: dict[str, _RenderedNode], +) -> str: + """Render a subgraph in the graphviz format.""" + params: list[tuple[str, str]] = [] + + # label + params.append(("label", _label(need, "center"))) + params.append(("tooltip", _quote(need["id_complete"]))) + + # link + if _link := calc_link(need): + params.extend([("href", _quote(_link)), ("target", _quote("_top"))]) + + # shape + if need["is_need"]: + params.append(("shape", _quote(need["type_style"]))) + else: + params.append(("shape", "rectangle")) + + # fill color + params.append(("style", "filled")) + if need["type_color"]: + params.append(("fillcolor", _quote(need["type_color"]))) + + # outline color + if node["highlight"] and filter_single_need(need, config, node["highlight"]): + params.append(("color", "red")) + elif node["border_color"]: + color = match_variants( + node["border_color"], + {**need}, + config.variants, + location=node, + ) + if color: + params.append(("color", _quote("#" + color))) + + # we need to create an invisible node to allow links to the subgraph + id = _quote(need["id_complete"]) + ghost_node = f'{id} [style=invis, width=0, height=0, label=""];' + + cluster_id = _quote("cluster_" + need["id_complete"]) + param_str = "\n".join(f" {key}={value};" for key, value in params) + + rendered_nodes[need["id_complete"]] = { + "need": need, + "cluster_id": "cluster_" + need["id_complete"], + } + + children = "" + if need["is_need"] and need["parts"]: + children += " // parts:\n" + for need_part_id in need["parts"]: + need_part_id = need["id"] + "." + need_part_id + if need_part_id in id_comp_to_need: + children += textwrap.indent( + _render_node( + id_comp_to_need[need_part_id], + node, + config, + calc_link, + id_comp_to_need, + rendered_nodes, + False, + ), + " ", + ) + if need["parent_needs_back"]: + children += " // child needs:\n" + for child_need_id in need["parent_needs_back"]: + if child_need_id in id_comp_to_need: + children += textwrap.indent( + _render_node( + id_comp_to_need[child_need_id], + node, + config, + calc_link, + id_comp_to_need, + rendered_nodes, + ), + " ", + ) + + return f"subgraph {cluster_id} {{\n{param_str}\n\n {ghost_node}\n{children}\n}};\n" + + +def _label(need: NeedsInfoType, align: Literal["left", "right", "center"]) -> str: + """Create the graphviz label for a need.""" + br = f'
' + # note this text wrapping mimics the jinja wordwrap filter + title = br.join( + br.join( + textwrap.wrap( + html.escape(line), + 15, + expand_tabs=False, + replace_whitespace=False, + break_long_words=True, + break_on_hyphens=True, + ) + ) + for line in need["title"].splitlines() + ) + name = html.escape(need["type_name"]) + if need["is_need"]: + _id = html.escape(need["id"]) + else: + _id = f"{html.escape(need['id_parent'])}.{html.escape(need['id'])}" + font_10 = '' + font_12 = '' + return f"<{font_12}{name}{br}{title}{br}{font_10}{_id}{br}>" + + +def _render_edge( + need: NeedsInfoType, + link: str, + link_type: LinkOptionsType, + node: NeedflowGraphiz, + config: NeedsSphinxConfig, + rendered_nodes: dict[str, _RenderedNode], +) -> str: + """Render an edge in the graphviz format.""" + if need["id_complete"] not in rendered_nodes or link not in rendered_nodes: + # if the start or end node is not rendered, we should not create a link + return "" + + show_links = node["show_link_names"] or config.flow_show_links + + params: list[tuple[str, str]] = [] + + if show_links: + params.append(("label", _quote(link_type["outgoing"]))) + + is_part = "." in link or "." in need["id_complete"] + params.extend( + _style_params_from_link_type( + link_type.get("style_part", "dotted") + if is_part + else link_type.get("style", ""), + link_type.get("style_start", "-"), + link_type.get("style_end", "->"), + ) + ) + + start_id = _quote(need["id_complete"]) + if (ltail := rendered_nodes[need["id_complete"]]["cluster_id"]) is not None: + # the need has been created as a subgraph and so we also need to create a logical link to the cluster + params.append(("ltail", _quote(ltail))) + + end_id = _quote(link) + if (lhead := rendered_nodes[link]["cluster_id"]) is not None: + # the end need has been created as a subgraph and so we also need to create a logical link to the cluster + params.append(("lhead", _quote(lhead))) + + param_str = ", ".join(f"{key}={value}" for key, value in params) + return f"{start_id} -> {end_id} [{param_str}];\n" + + +@lru_cache(maxsize=None) +def _style_params_from_link_type( + styles: str, style_start: str, style_end: str +) -> list[tuple[str, str]]: + params: list[tuple[str, str]] = [] + + for style in styles.split(","): + if not (style := style.strip()): + continue + if style.startswith("#"): + # assume this is a color + params.append(("color", _quote(style))) + elif style in ("dotted", "dashed", "solid", "bold"): + params.append(("style", _quote(style))) + else: + log_warning( + LOGGER, + f"Unknown link style {style!r} for graphviz engine", + "needflow", + None, + once=True, + ) + + # convert plantuml arrow start/end style to graphviz style. + plantuml_arrow_ends = style_start + style_end + # we are going to cheat a bit here and only look at the start and end characters + # this means we ignore things like the direction of the arrow, e.g. `-up->` + plantuml_arrow_ends = plantuml_arrow_ends[0] + plantuml_arrow_ends[-1] + if (arrow_style := _plantuml_arrow_style.get(plantuml_arrow_ends)) is None: + log_warning( + LOGGER, + f"Unknown link start/end style {plantuml_arrow_ends!r} for graphviz engine", + "needflow", + None, + once=True, + ) + else: + params.extend(arrow_style) + + return params + + +# in plantuml guide, see: 8.7 "Nestable elements" +# we try to match most to https://graphviz.org/doc/info/shapes.html +_plantuml_shapes = { + "agent": "box", + "artifact": "note", + "card": "box", + "component": "component", + "database": "cylinder", + "file": "note", + "folder": "folder", + "frame": "tab", + "hexagon": "hexagon", + "node": "box3d", + "package": "folder", + "queue": "cylinder", + "rectangle": "rectangle", + "stack": "rectangle", + "storage": "ellipse", + "usecase": "oval", +} + +# in plantuml guide, see: "8.13.1 Type of arrow head" +# we try to match most to https://graphviz.org/doc/info/arrows.html +# note -->> would actually be the normal style in graphviz +_plantuml_arrow_style = { + # neither + "--": (("arrowhead", "none"),), + # head only + "->": (("arrowhead", "vee"),), + "-*": (("arrowhead", "diamond"),), + "-o": (("arrowhead", "odiamond"),), + "-O": (("arrowhead", "odot"),), + "-@": (("arrowhead", "dot"),), + # tail only + "<-": (("dir", "back"), ("arrowtail", "vee")), + "*-": (("dir", "back"), ("arrowtail", "diamond")), + "o-": (("dir", "back"), ("arrowtail", "odiamond")), + "O-": (("dir", "back"), ("arrowtail", "odot")), + "@-": (("dir", "back"), ("arrowtail", "dot")), + # both same + "<>": (("dir", "both"), ("arrowtail", "vee"), ("arrowhead", "vee")), + "**": (("dir", "both"), ("arrowtail", "diamond"), ("arrowhead", "diamond")), + "oo": (("dir", "both"), ("arrowtail", "odiamond"), ("arrowhead", "odiamond")), + "OO": (("dir", "both"), ("arrowtail", "odot"), ("arrowhead", "odot")), + "@@": (("dir", "both"), ("arrowtail", "dot"), ("arrowhead", "dot")), + # both different + "*>": (("dir", "both"), ("arrowtail", "diamond"), ("arrowhead", "vee")), + "o>": (("dir", "both"), ("arrowtail", "odiamond"), ("arrowhead", "vee")), + "O>": (("dir", "both"), ("arrowtail", "odot"), ("arrowhead", "vee")), + "@>": (("dir", "both"), ("arrowtail", "dot"), ("arrowhead", "vee")), + "<*": (("dir", "both"), ("arrowtail", "vee"), ("arrowhead", "diamond")), + " str: + """Create a legend for the graph.""" + + # TODO also show links in legend + + # filter types by ones that are actually used + types = {need["type"] for need in needs} + need_types = [ntype for ntype in config.types if ntype["directive"] in types] + + label = '<' + label += '\n' + + for need_type in need_types: + title = html.escape(need_type["title"]) + color = _quote(need_type["color"]) + label += f'\n' + + label += "\n
Legend
{title}
>" + + legend = f""" +{{ + rank = sink; + legend [ + shape=box, + style=rounded, + label={label} + ]; +}} +""" + return legend + + +def html_visit_needflow_graphviz(self: HTML5Translator, node: NeedflowGraphiz) -> None: + """This visitor closely mimics ``sphinx.ext.graphviz.html_visit_graphviz``, + however, that is not used directly due to these current key differences: + + - The warning is changed, to give the location of the source directive + - svg's are output as ```` tags, not ```` tags (allows e.g. for transparency) + - svg's are wrapped in an `` tag, to allow for linking to the svg file + """ + code = node.get("resolved_content") + if code is None: + log_warning(LOGGER, "Content has not been resolved", "needflow", location=node) + raise nodes.SkipNode + attrributes = node.attributes + format = self.builder.config.graphviz_output_format + if format not in ("png", "svg"): + log_warning( + LOGGER, + f"graphviz_output_format must be one of 'png', 'svg', but is {format!r}", + "needflow", + None, + once=True, + ) + raise nodes.SkipNode + try: + fname, outfn = render_dot( + self, code, {"docname": attrributes["docname"]}, format, "needflow" + ) + except GraphvizError as exc: + log_warning( + LOGGER, + f"graphviz code failed to render (run with :debug: to see code): {exc}", + "needflow", + location=node, + ) + raise nodes.SkipNode from exc + + classes = ["graphviz", *attrributes.get("classes", [])] + imgcls = " ".join(filter(None, classes)) + + if fname is None: + self.body.append(self.encode(code)) + else: + alt = attrributes.get("alt", "needflow graphviz diagram") + if "align" in attrributes: + self.body.append( + f'
' + ) + if format == "svg": + self.body.append('\n") + else: + assert outfn is not None + with open(outfn + ".map", encoding="utf-8") as mapfile: + imgmap = ClickableMapDefinition( + outfn + ".map", mapfile.read(), dot=code + ) + if imgmap.clickable: + # has a map + self.body.append('
') + self.body.append( + f'{alt}' + ) + self.body.append("
\n") + self.body.append(imgmap.generate_clickable_map()) + else: + # nothing in image map + self.body.append('
') + self.body.append( + f'{alt}' + ) + self.body.append("
\n") + if "align" in attrributes: + self.body.append("
\n") + + raise nodes.SkipNode diff --git a/sphinx_needs/directives/needflow.py b/sphinx_needs/directives/needflow/_plantuml.py similarity index 53% rename from sphinx_needs/directives/needflow.py rename to sphinx_needs/directives/needflow/_plantuml.py index 3e13e7258..d5618773d 100644 --- a/sphinx_needs/directives/needflow.py +++ b/sphinx_needs/directives/needflow/_plantuml.py @@ -2,10 +2,10 @@ import html import os -from typing import Iterable, Literal, Sequence +from functools import lru_cache +from typing import Iterable from docutils import nodes -from docutils.parsers.rst import directives from jinja2 import Template from sphinx.application import Sphinx from sphinxcontrib.plantuml import ( @@ -20,106 +20,18 @@ ) from sphinx_needs.debug import measure_time from sphinx_needs.diagrams_common import calculate_link, create_legend +from sphinx_needs.directives.needflow._directive import NeedflowPlantuml from sphinx_needs.directives.utils import no_needs_found_paragraph -from sphinx_needs.filter_common import FilterBase, filter_single_need, process_filters +from sphinx_needs.filter_common import filter_single_need, process_filters from sphinx_needs.logging import get_logger, log_warning from sphinx_needs.utils import ( - add_doc, - get_scale, match_variants, remove_node_from_tree, - split_link_types, ) -logger = get_logger(__name__) - - -NEEDFLOW_TEMPLATES: dict[str, Template] = {} - +from ._shared import create_filter_paragraph, filter_by_tree, get_root_needs -class Needflow(nodes.General, nodes.Element): - pass - - -class NeedflowDirective(FilterBase): - """ - Directive to get flow charts. - """ - - optional_arguments = 1 - final_argument_whitespace = True - option_spec = { - "root_id": directives.unchanged_required, - "root_direction": lambda c: directives.choice( - c, ("both", "incoming", "outgoing") - ), - "root_depth": directives.nonnegative_int, - "show_legend": directives.flag, - "show_filters": directives.flag, - "show_link_names": directives.flag, - "link_types": directives.unchanged_required, - "config": directives.unchanged_required, - "scale": directives.unchanged_required, - "highlight": directives.unchanged_required, - "border_color": directives.unchanged_required, - "align": directives.unchanged_required, - "debug": directives.flag, - } - - # Update the options_spec with values defined in the FilterBase class - option_spec.update(FilterBase.base_option_spec) - - @measure_time("needflow") - def run(self) -> Sequence[nodes.Node]: - env = self.env - needs_config = NeedsSphinxConfig(env.config) - location = (env.docname, self.lineno) - - id = env.new_serialno("needflow") - targetid = f"needflow-{env.docname}-{id}" - targetnode = nodes.target("", "", ids=[targetid]) - - all_link_types = ",".join(x["option"] for x in needs_config.extra_links) - link_types = split_link_types( - self.options.get("link_types", all_link_types), location - ) - - config_names = self.options.get("config") - configs = [] - if config_names: - for config_name in config_names.split(","): - config_name = config_name.strip() - if config_name and config_name in needs_config.flow_configs: - configs.append(needs_config.flow_configs[config_name]) - - attributes: NeedsFlowType = { - "docname": env.docname, - "lineno": self.lineno, - "target_id": targetid, - "root_id": self.options.get("root_id"), - "root_direction": self.options.get("root_direction", "all"), - "root_depth": self.options.get("root_depth", None), - # note these are the same as DiagramBase.collect_diagram_attributes - "show_legend": "show_legend" in self.options, - "show_filters": "show_filters" in self.options, - "show_link_names": "show_link_names" in self.options, - "link_types": link_types, - "config": "\n".join(configs), - "config_names": config_names, - "scale": get_scale(self.options, location), - "highlight": self.options.get("highlight", ""), - "border_color": self.options.get("border_color", None), - "align": self.options.get("align"), - "debug": "debug" in self.options, - "caption": self.arguments[0] if self.arguments else None, - **self.collect_filter_attributes(), - } - node = Needflow("", **attributes) - self.set_source_info(node) - - add_doc(env, env.docname) - - return [targetnode, node] +logger = get_logger(__name__) def make_entity_name(name: str) -> str: @@ -205,40 +117,31 @@ def walk_curr_need_tree( if need["is_need"] and need["parts"]: # add comment for easy debugging curr_need_tree += "'parts:\n" - for need_part_id in need["parts"].keys(): + for need_part_id in need["parts"]: # cal need part node need_part_id = need["id"] + "." + need_part_id # get need part from need part id for found_need in found_needs: if need_part_id == found_need["id_complete"]: - need_part = found_need - # get need part node - need_part_node = get_need_node_rep_for_plantuml( - app, fromdocname, current_needflow, all_needs, need_part + curr_need_tree += ( + get_need_node_rep_for_plantuml( + app, fromdocname, current_needflow, all_needs, found_need + ) + + "\n" ) - curr_need_tree += need_part_node + "\n" + break # check if curr need has children if need["parent_needs_back"]: # add comment for easy debugging curr_need_tree += "'child needs:\n" - - # walk throgh all child needs one by one - child_needs_ids = need["parent_needs_back"] - - idx = 0 - while idx < len(child_needs_ids): - # start from one child - curr_child_need_id = child_needs_ids[idx] - # get need from id - for need in found_needs: - if need["id_complete"] == curr_child_need_id: - curr_child_need = need - # get child need node - child_need_node = get_need_node_rep_for_plantuml( + # walk through all child needs one by one + for curr_child_need_id in need["parent_needs_back"]: + for curr_child_need in found_needs: + if curr_child_need["id_complete"] == curr_child_need_id: + curr_need_tree += get_need_node_rep_for_plantuml( app, fromdocname, current_needflow, all_needs, curr_child_need ) - curr_need_tree += child_need_node # check curr need child has children or has parts if curr_child_need["parent_needs_back"] or curr_child_need["parts"]: curr_need_tree += walk_curr_need_tree( @@ -251,7 +154,7 @@ def walk_curr_need_tree( ) # add newline for next element curr_need_tree += "\n" - idx += 1 + break # We processed embedded needs or need parts, so we will close with "}" curr_need_tree += "}" @@ -259,24 +162,6 @@ def walk_curr_need_tree( return curr_need_tree -def get_root_needs(found_needs: list[NeedsInfoType]) -> list[NeedsInfoType]: - return_list = [] - for current_need in found_needs: - if current_need["is_need"]: - if "parent_need" not in current_need or current_need["parent_need"] == "": - # need has no parent, we have to add the need to the root needs - return_list.append(current_need) - else: - parent_found: bool = False - for elements in found_needs: - if elements["id"] == current_need["parent_need"]: - parent_found = True - break - if not parent_found: - return_list.append(current_need) - return return_list - - def cal_needs_node( app: Sphinx, fromdocname: str, @@ -306,50 +191,8 @@ def cal_needs_node( return curr_need_tree -def filter_by_tree( - all_needs: dict[str, NeedsInfoType], - root_id: str, - link_types: list[LinkOptionsType], - direction: Literal["both", "incoming", "outgoing"], - depth: int | None, -) -> dict[str, NeedsInfoType]: - """Filter all needs by the given ``root_id``, - and all needs that are connected to the root need by the given ``link_types``, in the given ``direction``.""" - need_items: dict[str, NeedsInfoType] = {} - if root_id not in all_needs: - return need_items - roots = {root_id: (0, all_needs[root_id])} - link_prefixes = ( - ("_back",) - if direction == "incoming" - else ("",) - if direction == "outgoing" - else ("", "_back") - ) - links_to_process = [ - link["option"] + d for link in link_types for d in link_prefixes - ] - while roots: - root_id, (root_depth, root) = roots.popitem() - if root_id in need_items: - continue - if depth is not None and root_depth > depth: - continue - need_items[root_id] = root - for link_type_name in links_to_process: - roots.update( - { - i: (root_depth + 1, all_needs[i]) - for i in root.get(link_type_name, []) # type: ignore[attr-defined] - if i in all_needs - } - ) - - return need_items - - -@measure_time("needflow") -def process_needflow( +@measure_time("needflow_plantuml") +def process_needflow_plantuml( app: Sphinx, doctree: nodes.document, fromdocname: str, @@ -365,9 +208,8 @@ def process_needflow( link_type_names = [link["option"].upper() for link in needs_config.extra_links] allowed_link_types_options = [link.upper() for link in needs_config.flow_link_types] - # NEEDFLOW - # for node in doctree.findall(Needflow): - for node in found_nodes: + node: NeedflowPlantuml + for node in found_nodes: # type: ignore[assignment] if not needs_config.include_needs: remove_node_from_tree(node) continue @@ -438,13 +280,16 @@ def process_needflow( if found_needs: plantuml_block_text = ".. plantuml::\n" "\n" " @startuml" " @enduml" puml_node = plantuml(plantuml_block_text) + # TODO if an alt is not set then sphinxcontrib.plantuml uses the plantuml source code as alt text. + # I think this is not great, but currently setting a more sensible default breaks some tests + if current_needflow["alt"]: + puml_node["alt"] = current_needflow["alt"] # Add source origin puml_node.line = current_needflow["lineno"] puml_node.source = env.doc2path(current_needflow["docname"]) puml_node["uml"] = "@startuml\n" - puml_connections = "" # Adding config config = current_needflow["config"] @@ -458,75 +303,16 @@ def process_needflow( puml_node["uml"] += "\n\n" puml_node["uml"] += "\n' Nodes definition \n\n" - - for need_info in found_needs: - for link_type in allowed_link_types: - for link in need_info[link_type["option"]]: # type: ignore[literal-required] - # If source or target of link is a need_part, a specific style is needed - if "." in link or "." in need_info["id_complete"]: - final_link = link - if ( - current_needflow["show_link_names"] - or needs_config.flow_show_links - ): - desc = link_type["outgoing"] + "\\n" - comment = f": {desc}" - else: - comment = "" - - if _style_part := link_type.get("style_part"): - link_style = f"[{_style_part}]" - else: - link_style = "[dotted]" - else: - final_link = link - if ( - current_needflow["show_link_names"] - or needs_config.flow_show_links - ): - comment = ": {desc}".format(desc=link_type["outgoing"]) - else: - comment = "" - - if _style := link_type.get("style"): - link_style = f"[{_style}]" - else: - link_style = "" - - # Do not create an links, if the link target is not part of the search result. - if final_link not in [ - x["id"] for x in found_needs if x["is_need"] - ] and final_link not in [ - x["id_complete"] for x in found_needs if x["is_part"] - ]: - continue - - if _style_start := link_type.get("style_start"): - style_start = _style_start - else: - style_start = "-" - - if _style_end := link_type.get("style_end"): - style_end = _style_end - else: - style_end = "->" - - puml_connections += "{id} {style_start}{link_style}{style_end} {link}{comment}\n".format( - id=make_entity_name(need_info["id_complete"]), - link=make_entity_name(final_link), - comment=comment, - link_style=link_style, - style_start=style_start, - style_end=style_end, - ) - - # calculate needs node representation for plantuml puml_node["uml"] += cal_needs_node( app, fromdocname, current_needflow, all_needs.values(), found_needs ) puml_node["uml"] += "\n' Connection definition \n\n" - puml_node["uml"] += puml_connections + puml_node["uml"] += render_connections( + found_needs, + allowed_link_types, + current_needflow["show_link_names"] or needs_config.flow_show_links, + ) # Create a legend if current_needflow["show_legend"]: @@ -582,35 +368,7 @@ def process_needflow( ) if current_needflow["show_filters"]: - para = nodes.paragraph() - filter_text = "Used filter:" - filter_text += ( - " status({})".format(" OR ".join(current_needflow["status"])) - if len(current_needflow["status"]) > 0 - else "" - ) - if ( - len(current_needflow["status"]) > 0 - and len(current_needflow["tags"]) > 0 - ): - filter_text += " AND " - filter_text += ( - " tags({})".format(" OR ".join(current_needflow["tags"])) - if len(current_needflow["tags"]) > 0 - else "" - ) - if ( - len(current_needflow["status"]) > 0 or len(current_needflow["tags"]) > 0 - ) and len(current_needflow["types"]) > 0: - filter_text += " AND " - filter_text += ( - " types({})".format(" OR ".join(current_needflow["types"])) - if len(current_needflow["types"]) > 0 - else "" - ) - - filter_node = nodes.emphasis(filter_text, filter_text) - para += filter_node + para = create_filter_paragraph(current_needflow) content.append(para) # We have to restrustructer the needflow @@ -631,10 +389,66 @@ def process_needflow( node.replace_self(content) -def get_template(template_name: str) -> Template: - """Checks if a template got already rendered, if it's the case, return it""" +def render_connections( + found_needs: list[NeedsInfoType], + allowed_link_types: list[LinkOptionsType], + show_links: bool, +) -> str: + """ + Render the connections between the needs. + """ + puml_connections = "" + for need_info in found_needs: + for link_type in allowed_link_types: + for link in need_info[link_type["option"]]: # type: ignore[literal-required] + # Do not create an links, if the link target is not part of the search result. + if link not in [ + x["id"] for x in found_needs if x["is_need"] + ] and link not in [ + x["id_complete"] for x in found_needs if x["is_part"] + ]: + continue + + if show_links: + desc = link_type["outgoing"] + "\\n" + comment = f": {desc}" + else: + comment = "" + + # If source or target of link is a need_part, a specific style is needed + if "." in link or "." in need_info["id_complete"]: + if _style_part := link_type.get("style_part"): + link_style = f"[{_style_part}]" + else: + link_style = "[dotted]" + else: + if _style := link_type.get("style"): + link_style = f"[{_style}]" + else: + link_style = "" + + if _style_start := link_type.get("style_start"): + style_start = _style_start + else: + style_start = "-" + + if _style_end := link_type.get("style_end"): + style_end = _style_end + else: + style_end = "->" + + puml_connections += "{id} {style_start}{link_style}{style_end} {link}{comment}\n".format( + id=make_entity_name(need_info["id_complete"]), + link=make_entity_name(link), + comment=comment, + link_style=link_style, + style_start=style_start, + style_end=style_end, + ) + return puml_connections - if template_name not in NEEDFLOW_TEMPLATES: - NEEDFLOW_TEMPLATES[template_name] = Template(template_name) - return NEEDFLOW_TEMPLATES[template_name] +@lru_cache +def get_template(template_name: str) -> Template: + """Checks if a template got already rendered, if it's the case, return it""" + return Template(template_name) diff --git a/sphinx_needs/directives/needflow/_shared.py b/sphinx_needs/directives/needflow/_shared.py new file mode 100644 index 000000000..15db87dab --- /dev/null +++ b/sphinx_needs/directives/needflow/_shared.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Literal + +from docutils import nodes + +from sphinx_needs.config import LinkOptionsType +from sphinx_needs.data import ( + NeedsFlowType, + NeedsInfoType, +) +from sphinx_needs.logging import get_logger + +logger = get_logger(__name__) + + +def filter_by_tree( + all_needs: dict[str, NeedsInfoType], + root_id: str, + link_types: list[LinkOptionsType], + direction: Literal["both", "incoming", "outgoing"], + depth: int | None, +) -> dict[str, NeedsInfoType]: + """Filter all needs by the given ``root_id``, + and all needs that are connected to the root need by the given ``link_types``, in the given ``direction``.""" + need_items: dict[str, NeedsInfoType] = {} + if root_id not in all_needs: + return need_items + roots = {root_id: (0, all_needs[root_id])} + link_prefixes = ( + ("_back",) + if direction == "incoming" + else ("",) + if direction == "outgoing" + else ("", "_back") + ) + links_to_process = [ + link["option"] + d for link in link_types for d in link_prefixes + ] + while roots: + root_id, (root_depth, root) = roots.popitem() + if root_id in need_items: + continue + if depth is not None and root_depth > depth: + continue + need_items[root_id] = root + for link_type_name in links_to_process: + roots.update( + { + i: (root_depth + 1, all_needs[i]) + for i in root.get(link_type_name, []) # type: ignore[attr-defined] + if i in all_needs + } + ) + + return need_items + + +def get_root_needs(found_needs: list[NeedsInfoType]) -> list[NeedsInfoType]: + return_list = [] + for current_need in found_needs: + if current_need["is_need"]: + if "parent_need" not in current_need or current_need["parent_need"] == "": + # need has no parent, we have to add the need to the root needs + return_list.append(current_need) + else: + parent_found: bool = False + for elements in found_needs: + if elements["id"] == current_need["parent_need"]: + parent_found = True + break + if not parent_found: + return_list.append(current_need) + return return_list + + +def create_filter_paragraph(data: NeedsFlowType) -> nodes.paragraph: + para = nodes.paragraph() + filter_text = "Used filter:" + filter_text += ( + " status({})".format(" OR ".join(data["status"])) + if len(data["status"]) > 0 + else "" + ) + if len(data["status"]) > 0 and len(data["tags"]) > 0: + filter_text += " AND " + filter_text += ( + " tags({})".format(" OR ".join(data["tags"])) if len(data["tags"]) > 0 else "" + ) + if (len(data["status"]) > 0 or len(data["tags"]) > 0) and len(data["types"]) > 0: + filter_text += " AND " + filter_text += ( + " types({})".format(" OR ".join(data["types"])) + if len(data["types"]) > 0 + else "" + ) + + filter_node = nodes.emphasis(filter_text, filter_text) + para += filter_node + + return para diff --git a/sphinx_needs/directives/needuml.py b/sphinx_needs/directives/needuml.py index 20b6797ff..6f277382c 100644 --- a/sphinx_needs/directives/needuml.py +++ b/sphinx_needs/directives/needuml.py @@ -14,7 +14,7 @@ from sphinx_needs.data import NeedsInfoType, SphinxNeedsData from sphinx_needs.debug import measure_time from sphinx_needs.diagrams_common import calculate_link -from sphinx_needs.directives.needflow import make_entity_name +from sphinx_needs.directives.needflow._plantuml import make_entity_name from sphinx_needs.filter_common import filter_needs from sphinx_needs.utils import add_doc diff --git a/sphinx_needs/filter_common.py b/sphinx_needs/filter_common.py index 53d21b987..9f51bd11d 100644 --- a/sphinx_needs/filter_common.py +++ b/sphinx_needs/filter_common.py @@ -35,9 +35,9 @@ class FilterAttributesType(TypedDict): filter: str sort_by: str filter_code: list[str] - filter_func: str + filter_func: str | None export_id: str - filter_warning: str + filter_warning: str | None """If set, the filter is exported with this ID in the needs.json file.""" diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index 76ee6a6d9..056ed6295 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -22,6 +22,7 @@ from sphinx_needs.config import NEEDS_CONFIG, LinkOptionsType, NeedsSphinxConfig from sphinx_needs.data import NeedsCoreFields, SphinxNeedsData, merge_data from sphinx_needs.defaults import ( + GRAPHVIZ_STYLE_DEFAULTS, LAYOUTS, NEED_DEFAULT_OPTIONS, NEEDEXTEND_NOT_ALLOWED_OPTIONS, @@ -52,9 +53,12 @@ process_needfilters, ) from sphinx_needs.directives.needflow import ( - Needflow, NeedflowDirective, - process_needflow, + NeedflowGraphiz, + NeedflowPlantuml, + html_visit_needflow_graphviz, + process_needflow_graphviz, + process_needflow_plantuml, ) from sphinx_needs.directives.needgantt import ( Needgantt, @@ -124,7 +128,8 @@ Needfilter: process_needfilters, Needlist: process_needlist, Needtable: process_needtables, - Needflow: process_needflow, + NeedflowPlantuml: process_needflow_plantuml, + NeedflowGraphiz: process_needflow_graphviz, Needpie: process_needpie, Needsequence: process_needsequence, Needgantt: process_needgantt, @@ -145,6 +150,7 @@ def setup(app: Sphinx) -> dict[str, Any]: LOGGER.debug("Load Sphinx-Data-Viewer for Sphinx-Needs") app.setup_extension("sphinx_data_viewer") app.setup_extension("sphinxcontrib.jquery") + app.setup_extension("sphinx.ext.graphviz") app.add_builder(NeedsBuilder) app.add_builder(NeedumlsBuilder) @@ -163,7 +169,8 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_node(Needimport) app.add_node(Needlist) app.add_node(Needtable) - app.add_node(Needflow) + app.add_node(NeedflowPlantuml) + app.add_node(NeedflowGraphiz, html=(html_visit_needflow_graphviz, None)) app.add_node(Needpie) app.add_node(Needsequence) app.add_node(Needgantt) @@ -567,7 +574,14 @@ def prepare_env(app: Sphinx, env: BuildEnvironment, _docname: str) -> None: needs_config.extra_links = common_links + needs_config.extra_links needs_config.layouts = {**LAYOUTS, **needs_config.layouts} - needs_config.flow_configs.update(NEEDFLOW_CONFIG_DEFAULTS) + needs_config.flow_configs = { + **NEEDFLOW_CONFIG_DEFAULTS, + **needs_config.flow_configs, + } + needs_config.graphviz_styles = { + **GRAPHVIZ_STYLE_DEFAULTS, + **needs_config.graphviz_styles, + } # Set time measurement flag if needs_config.debug_measurement: diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py index d9a1c4f92..7472efcc8 100644 --- a/sphinx_needs/utils.py +++ b/sphinx_needs/utils.py @@ -492,7 +492,7 @@ def match_variants( context: dict[str, Any], variants: dict[str, str], *, - location: str | tuple[str | None, int | None] | None = None, + location: str | tuple[str | None, int | None] | nodes.Node | None = None, ) -> str | None: """Evaluate an options list and return the first matching variant. diff --git a/tests/doc_test/doc_needflow_incl_child_needs/index.rst b/tests/doc_test/doc_needflow_incl_child_needs/index.rst index 07769ec64..f176c298d 100644 --- a/tests/doc_test/doc_needflow_incl_child_needs/index.rst +++ b/tests/doc_test/doc_needflow_incl_child_needs/index.rst @@ -46,6 +46,4 @@ TEST DOCUMENT NEEDFLOW INCL CHILD NEEDS .. needflow:: :show_link_names: - :debug: - - + :show_legend: diff --git a/tests/test_needflow.py b/tests/test_needflow.py index e708573f4..a0aa492eb 100644 --- a/tests/test_needflow.py +++ b/tests/test_needflow.py @@ -1,56 +1,93 @@ -from pathlib import Path +import os +from pathlib import Path, PurePosixPath import pytest -from docutils import __version__ as doc_ver +from lxml import html as html_parser +from sphinx.config import Config +from sphinx.util.console import strip_colors + + +def _get_svg(config: Config, outdir: Path, file: str, id: str) -> str: + root_tree = html_parser.parse(outdir / file) + if config.needs_flow_engine == "plantuml": + graph_nodes = root_tree.xpath(f"//figure[@id='{id}']/p/object") + assert len(graph_nodes) == 1 + return (outdir / PurePosixPath(graph_nodes[0].attrib["data"])).read_text("utf8") + else: + graph_nodes = root_tree.xpath(f"//figure[@id='{id}']/div/a") + assert len(graph_nodes) == 1 + return (outdir / PurePosixPath(graph_nodes[0].attrib["href"])).read_text("utf8") @pytest.mark.parametrize( "test_app", - [{"buildername": "html", "srcdir": "doc_test/doc_needflow"}], + [ + { + "buildername": "html", + "srcdir": "doc_test/doc_needflow", + "confoverrides": {"needs_flow_engine": "plantuml"}, + }, + { + "buildername": "html", + "srcdir": "doc_test/doc_needflow", + "confoverrides": { + "needs_flow_engine": "graphviz", + "graphviz_output_format": "svg", + }, + }, + ], indirect=True, ) def test_doc_build_html(test_app): app = test_app app.build() - # stdout warnings - warning = app._warning - warnings = warning.getvalue() - # plantuml shall not return any warnings: - assert "WARNING: error while running plantuml" not in warnings - - index_html = Path(app.outdir, "index.html").read_text() - assert "SPEC_1 [[../index.html#SPEC_1]]" in index_html - assert "SPEC_2 [[../index.html#SPEC_2]]" in index_html - assert "STORY_1 [[../index.html#STORY_1]]" in index_html - assert "STORY_1.1 [[../index.html#STORY_1.1]]" in index_html - assert "STORY_1.2 [[../index.html#STORY_1.2]]" in index_html - assert "STORY_1.subspec [[../index.html#STORY_1.subspec]]" in index_html - assert "STORY_2 [[../index.html#STORY_2]]" in index_html - assert "STORY_2.another_one [[../index.html#STORY_2.another_one]]" in index_html - - if int(doc_ver.split(".")[1]) >= 18: - assert '
' in index_html - else: - assert '
' in index_html - - assert "No needs passed the filters" in index_html - - page_html = Path(app.outdir, "page.html").read_text() - assert "SPEC_1 [[../index.html#SPEC_1]]" in page_html - assert "SPEC_2 [[../index.html#SPEC_2]]" in page_html - assert "STORY_1 [[../index.html#STORY_1]]" in page_html - assert "STORY_1.1 [[../index.html#STORY_1.1]]" in page_html - assert "STORY_1.2 [[../index.html#STORY_1.2]]" in page_html - assert "STORY_1.subspec [[../index.html#STORY_1.subspec]]" in page_html - assert "STORY_2 [[../index.html#STORY_2]]" in page_html - assert "STORY_2.another_one [[../index.html#STORY_2.another_one]]" in page_html - - with_rootid = Path(app.outdir, "needflow_with_root_id.html").read_text() - assert "SPEC_1" in with_rootid - assert "STORY_1" in with_rootid - assert "STORY_2" in with_rootid - assert "SPEC_2" not in with_rootid + warnings = ( + strip_colors(app._warning.getvalue()) + .replace(str(app.srcdir) + os.path.sep, "/") + .strip() + ) + assert warnings == "" + + outdir = Path(app.outdir) + svg = _get_svg(app.config, outdir, "index.html", "needflow-index-0") + for link in ( + "./index.html#SPEC_1", + "./index.html#SPEC_2", + "./index.html#STORY_1", + "./index.html#STORY_1.1", + "./index.html#STORY_1.2", + "./index.html#STORY_1.subspec", + "./index.html#STORY_2", + "./index.html#STORY_2.another_one", + ): + assert link in svg + assert "No needs passed the filters" in Path(app.outdir, "index.html").read_text() + + svg = _get_svg(app.config, outdir, "page.html", "needflow-page-0") + for link in ( + "./index.html#SPEC_1", + "./index.html#SPEC_2", + "./index.html#STORY_1", + "./index.html#STORY_1.1", + "./index.html#STORY_1.2", + "./index.html#STORY_1.subspec", + "./index.html#STORY_2", + "./index.html#STORY_2.another_one", + ): + assert link in svg + + svg = _get_svg( + app.config, + outdir, + "needflow_with_root_id.html", + "needflow-needflow_with_root_id-0", + ) + print(svg) + for link in ("SPEC_1", "STORY_1", "STORY_2"): + assert link in svg + + assert "SPEC_2" not in svg empty_needflow_with_debug = Path( app.outdir, "empty_needflow_with_debug.html" @@ -60,103 +97,128 @@ def test_doc_build_html(test_app): @pytest.mark.parametrize( "test_app", - [{"buildername": "html", "srcdir": "doc_test/doc_needflow_incl_child_needs"}], + [ + { + "buildername": "html", + "srcdir": "doc_test/doc_needflow_incl_child_needs", + "confoverrides": {"needs_flow_engine": "plantuml"}, + }, + { + "buildername": "html", + "srcdir": "doc_test/doc_needflow_incl_child_needs", + "confoverrides": { + "needs_flow_engine": "graphviz", + "graphviz_output_format": "svg", + }, + }, + ], indirect=True, ) def test_doc_build_needflow_incl_child_needs(test_app): app = test_app app.build() - # stdout warnings - warning = app._warning - warnings = warning.getvalue() - # plantuml shall not return any warnings: - assert "WARNING: error while running plantuml" not in warnings - - index_html = Path(app.outdir, "index.html").read_text() - assert index_html - assert index_html.count("@startuml") == 1 - assert index_html.count("[[../index.html#STORY_1]]") == 2 - assert index_html.count("[[../index.html#STORY_1.1]]") == 2 - assert index_html.count("[[../index.html#STORY_1.2]]") == 2 - assert index_html.count("[[../index.html#STORY_2]]") == 2 - assert index_html.count("[[../index.html#STORY_2.3]]") == 2 - assert index_html.count("[[../index.html#SPEC_1]]") == 2 - assert index_html.count("[[../index.html#SPEC_2]]") == 2 - assert index_html.count("[[../index.html#SPEC_3]]") == 2 - assert index_html.count("[[../index.html#SPEC_4]]") == 2 - assert index_html.count("[[../index.html#STORY_3]]") == 2 - assert index_html.count("[[../index.html#SPEC_5]]") == 2 - assert index_html.count("@enduml") == 1 - - single_parent_need_filer_html = Path( - app.outdir, "single_parent_need_filer.html" - ).read_text() - assert single_parent_need_filer_html - assert single_parent_need_filer_html.count("@startuml") == 1 - assert single_parent_need_filer_html.count("[[../index.html#STORY_3]]") == 2 - assert single_parent_need_filer_html.count("@enduml") == 1 - assert "[[../index.html#STORY_1]]" not in single_parent_need_filer_html - assert "[[../index.html#STORY_1.1]]" not in single_parent_need_filer_html - assert "[[../index.html#STORY_1.2]]" not in single_parent_need_filer_html - assert "[[../index.html#STORY_2]]" not in single_parent_need_filer_html - assert "[[../index.html#STORY_2.3]]" not in single_parent_need_filer_html - assert "[[../index.html#SPEC_1]]" not in single_parent_need_filer_html - assert "[[../index.html#SPEC_2]]" not in single_parent_need_filer_html - assert "[[../index.html#SPEC_3]]" not in single_parent_need_filer_html - assert "[[../index.html#SPEC_4]]" not in single_parent_need_filer_html - assert "[[../index.html#SPEC_5]]" not in single_parent_need_filer_html - - single_child_with_child_need_filter_html = Path( - app.outdir, "single_child_with_child_need_filter.html" - ).read_text() - assert single_child_with_child_need_filter_html - assert single_child_with_child_need_filter_html.count("@startuml") == 1 - assert ( - single_child_with_child_need_filter_html.count("[[../index.html#STORY_2]]") == 2 + warnings = ( + strip_colors(app._warning.getvalue()) + .replace(str(app.srcdir) + os.path.sep, "/") + .strip() ) - assert single_child_with_child_need_filter_html.count("@enduml") == 1 - assert "[[../index.html#STORY_1]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#STORY_1.1]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#STORY_1.2]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#STORY_2.3]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#SPEC_1]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#SPEC_2]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#SPEC_3]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#SPEC_4]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#STORY_3]]" not in single_child_with_child_need_filter_html - assert "[[../index.html#SPEC_5]]" not in single_child_with_child_need_filter_html - - single_child_need_filter_html = Path( - app.outdir, "single_child_need_filter.html" - ).read_text() - assert single_child_need_filter_html - assert single_child_need_filter_html.count("@startuml") == 1 - assert single_child_need_filter_html.count("[[../index.html#SPEC_1]]") == 2 - assert single_child_need_filter_html.count("@enduml") == 1 - assert "[[../index.html#STORY_1]]" not in single_child_need_filter_html - assert "[[../index.html#STORY_1.1]]" not in single_child_need_filter_html - assert "[[../index.html#STORY_1.2]]" not in single_child_need_filter_html - assert "[[../index.html#STORY_2]]" not in single_child_need_filter_html - assert "[[../index.html#STORY_2.3]]" not in single_child_need_filter_html - assert "[[../index.html#SPEC_2]]" not in single_child_need_filter_html - assert "[[../index.html#SPEC_3]]" not in single_child_need_filter_html - assert "[[../index.html#SPEC_4]]" not in single_child_need_filter_html - assert "[[../index.html#STORY_3]]" not in single_child_need_filter_html - assert "[[../index.html#SPEC_5]]" not in single_child_need_filter_html - - grandy_and_child_html = Path(app.outdir, "grandy_and_child.html").read_text() - assert grandy_and_child_html - assert grandy_and_child_html.count("@startuml") == 1 - assert grandy_and_child_html.count("[[../index.html#STORY_1]]") == 2 - assert grandy_and_child_html.count("[[../index.html#SPEC_1]]") == 2 - assert grandy_and_child_html.count("[[../index.html#SPEC_2]]") == 2 - assert grandy_and_child_html.count("@enduml") == 1 - assert "[[../index.html#STORY_1.1]]" not in grandy_and_child_html - assert "[[../index.html#STORY_1.2]]" not in grandy_and_child_html - assert "[[../index.html#STORY_2]]" not in grandy_and_child_html - assert "[[../index.html#STORY_2.3]]" not in grandy_and_child_html - assert "[[../index.html#SPEC_3]]" not in grandy_and_child_html - assert "[[../index.html#SPEC_4]]" not in grandy_and_child_html - assert "[[../index.html#STORY_3]]" not in grandy_and_child_html - assert "[[../index.html#SPEC_5]]" not in grandy_and_child_html + assert warnings == "" + + outdir = Path(app.outdir) + + svg = _get_svg(app.config, outdir, "index.html", "needflow-index-0") + for link in ( + "./index.html#STORY_1", + "./index.html#STORY_1.1", + "./index.html#STORY_1.2", + "./index.html#STORY_2", + "./index.html#STORY_2.3", + "./index.html#SPEC_1", + "./index.html#SPEC_2", + "./index.html#SPEC_3", + "./index.html#SPEC_4", + "./index.html#STORY_3", + "./index.html#SPEC_5", + ): + assert link in svg + + svg = _get_svg( + app.config, + outdir, + "single_parent_need_filer.html", + "needflow-single_parent_need_filer-0", + ) + assert "./index.html#STORY_3" in svg + for link in ( + "./index.html#STORY_1", + "./index.html#STORY_1.1", + "./index.html#STORY_1.2", + "./index.html#STORY_2", + "./index.html#STORY_2.3", + "./index.html#SPEC_1", + "./index.html#SPEC_2", + "./index.html#SPEC_3", + "./index.html#SPEC_4", + "./index.html#SPEC_5", + ): + assert link not in svg + + svg = _get_svg( + app.config, + outdir, + "single_child_with_child_need_filter.html", + "needflow-single_child_with_child_need_filter-0", + ) + assert "./index.html#STORY_2" in svg + for link in ( + "./index.html#STORY_1", + "./index.html#STORY_1.1", + "./index.html#STORY_1.2", + "./index.html#STORY_2.3", + "./index.html#SPEC_1", + "./index.html#SPEC_2", + "./index.html#SPEC_3", + "./index.html#SPEC_4", + "./index.html#STORY_3", + "./index.html#SPEC_5", + ): + assert link not in svg + + svg = _get_svg( + app.config, + outdir, + "single_child_need_filter.html", + "needflow-single_child_need_filter-0", + ) + assert "./index.html#SPEC_1" in svg + for link in ( + "./index.html#STORY_1", + "./index.html#STORY_1.1", + "./index.html#STORY_1.2", + "./index.html#STORY_2", + "./index.html#STORY_2.3", + "./index.html#SPEC_2", + "./index.html#SPEC_3", + "./index.html#SPEC_4", + "./index.html#STORY_3", + "./index.html#SPEC_5", + ): + assert link not in svg + + svg = _get_svg( + app.config, outdir, "grandy_and_child.html", "needflow-grandy_and_child-0" + ) + for link in ("./index.html#STORY_1", "./index.html#SPEC_1", "./index.html#SPEC_2"): + assert link in svg + for link in ( + "./index.html#STORY_1.1", + "./index.html#STORY_1.2", + "./index.html#STORY_2", + "./index.html#STORY_2.3", + "./index.html#SPEC_3", + "./index.html#SPEC_4", + "./index.html#STORY_3", + "./index.html#SPEC_5", + ): + assert link not in svg