From 295c254fba693c4b726bac94bb5d64677e416422 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 26 Aug 2024 16:32:20 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Allow=20for=20use=20of=20graphviz?= =?UTF-8?q?=20as=20the=20"engine"=20for=20`needflow`=20(#1235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit allows for the use of [`graphviz`](https://graphviz.org) as the underlying engine for `needflow` diagrams, in addition to the default [`plantuml`](https://plantuml.com). The intention being to simplify and improve performance of graph builds, since plantuml has issues with JVM initialisation times and reliance on a third-party sphinx extension. It introduces a new configuration option; `needs_flow_engine`, which can be set to either `plantuml` or `graphviz`, and defaults to `plantuml`, and a new directive option; `engine`, which can be set to either `plantuml` or `graphviz`, and defaults to `plantuml`. Thus, the `graphivz` engine can be activated on a per-diagram basis, or globally. The `graphviz` engine supports all the existing `needflow` options, although one key complication is the translation of existing "style related" configurations, which are hard-coded to plantuml syntax: - fields in `needs_extra_links`: `style`, `style_part`, `style_start`, `style_end` - `needs_flow_configs` - `needs_diagram_template` The `needs_extra_links` fields are translated to graphviz syntax, where possible, and warnings emitted for those that could not be. Two issues that probably cannot be resolved (it is unclear exactly how plantuml achieves them): 1. Link styles with a direction, such as `-up->` 2. Applying correct shapes for needs that contain children/parts; in graphviz nodes cannot contain other nodes, and instead we must use "subgraph clusters" for such nodes, which do not allow a `shape` attribute A separate `needs_graphviz_styles` configuration is introduced, which is similar to `needs_flow_configs`, except allowing graphviz attributes to be set (and used by the `needflow` directive's `config` option). `needs_diagram_template` is not used; currently the graphviz engine hard-codes the format of node labels, but this could potentially be added in the future. Additionally, an `alt` option is added to the `needflow` directive, and supported by both engines. Note, currently the rendering with the `graphviz` engine is restricted to HTML output only, this could fairly easily be extended to latex/man/text/texinfo, which `sphinx.ext.graphviz` already supports, the main reason it is not already, is that we override the visitor node currently, to make some improvements. --- .github/workflows/ci.yaml | 6 + docs/conf.py | 24 + docs/configuration.rst | 85 ++- docs/directives/needflow.rst | 186 ++++- docs/tutorial.rst | 26 +- sphinx_needs/config.py | 7 + sphinx_needs/data.py | 26 +- sphinx_needs/defaults.py | 31 +- sphinx_needs/diagrams_common.py | 14 +- sphinx_needs/directives/needflow/__init__.py | 12 + .../directives/needflow/_directive.py | 208 ++++++ sphinx_needs/directives/needflow/_graphviz.py | 658 ++++++++++++++++++ .../{needflow.py => needflow/_plantuml.py} | 374 +++------- sphinx_needs/directives/needflow/_shared.py | 101 +++ sphinx_needs/directives/needuml.py | 2 +- sphinx_needs/filter_common.py | 4 +- sphinx_needs/needs.py | 24 +- sphinx_needs/utils.py | 2 +- .../doc_needflow_incl_child_needs/index.rst | 4 +- tests/test_needflow.py | 330 +++++---- 20 files changed, 1677 insertions(+), 447 deletions(-) create mode 100644 sphinx_needs/directives/needflow/__init__.py create mode 100644 sphinx_needs/directives/needflow/_directive.py create mode 100644 sphinx_needs/directives/needflow/_graphviz.py rename sphinx_needs/directives/{needflow.py => needflow/_plantuml.py} (53%) create mode 100644 sphinx_needs/directives/needflow/_shared.py 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