diff --git a/docs/conf.py b/docs/conf.py index 570cea14e..c38c0524c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,6 +50,8 @@ ("py:class", "sphinx_needs.debug.T"), ("py:class", "sphinx_needs.views._LazyIndexes"), ("py:class", "sphinx_needs.config.NeedsSphinxConfig"), + ("py:class", "sphinx_needs.nodes.Need"), + ("py:class", "sphinx_needs.data.SphinxNeedsData"), ] rst_epilog = """ diff --git a/docs/directives/index.rst b/docs/directives/index.rst index 108b5f521..7c8df0f28 100644 --- a/docs/directives/index.rst +++ b/docs/directives/index.rst @@ -8,6 +8,7 @@ Directives for creating and modifying needs: need list2need + list-needs needextend needextract needimport diff --git a/docs/directives/list-needs.rst b/docs/directives/list-needs.rst new file mode 100644 index 000000000..1688e6d99 --- /dev/null +++ b/docs/directives/list-needs.rst @@ -0,0 +1,119 @@ +.. _list-needs: + +list-needs +---------- + +.. versionadded:: 5.2.0 + +``list-needs`` provides a shorthand notation to create multiple nested needs in one go. + +The content of the directive should contain a standard :external+sphinx:ref:`rst-field-lists` block, +with each item in the list representing a need. + +Similar to the :ref:`need directive `, *field name* should start with the need type. +Proceeding options in the field name can then be specified as white-space delimited; keys with no values (``key``), +keys with simple (non-whitespace) values (``key=value``), or keys with quoted values (``key="value with space"``). + +Allowed field name options are: +``id``, ``title``, ``status``, ``tags``, ``collapse``, ``delete``, ``hide``, ``layout``, ``style``, ``constraints``, +:ref:`needs_extra_options`, and :ref:`needs_extra_links`. + +Unless specified in the field name parameters, the **title** is taken as the first paragraph of the field content, +and the **content** is taken as the rest of the field content. + +.. need-example:: Simple ``list-needs`` example + + .. list-needs:: + + :req id=LIST-1a: Need example title + + Need example on level 1. + :req id=LIST-1b: + Another Need example with nested needs. + + :spec id=LIST-s2a status=open tags=list-tag1,list-tag2 author="John Doe": + Sub-Need on level 2 with other options set + :spec id=LIST-s2b title="Another Sub-Need on level 2.": + With the title given in the parameters. + + :test id=LIST-s3 collapse: Sub-Need on level 3. + + Content can contain standard *syntax*. + +Options +------- + +``defaults`` +~~~~~~~~~~~~ + +This option allows you to set default values for all needs in the list, it is parsed as a field list similar to the :ref:`need directive options `. +Defaults will be overridden by any options set in the field name. + +.. need-example:: ``defaults`` option + + .. list-needs:: + :defaults: + :status: open + :tags: list-tag1,list-tag2 + + :req id=LIST-d1: Need level 1 + + :spec id=LIST-d2 status=closed: Sub-Need level 2 + + + +``maxdepth`` +~~~~~~~~~~~~ + +The ``maxdepth`` option allows you to limit the depth of converted field lists. + +.. need-example:: ``maxdepth`` option + + .. list-needs:: + :maxdepth: 1 + + :req id=LIST-m1: Need level 1 + + :normal: field list + +``links-up`` and ``links-down`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``links-up`` and ``links-down`` options allow you to define links between needs in the list, +according to their structure. +Both are a comma-delimited list, with each item representing a link type for the corresponding level (starting from 1). + +.. need-example:: ``links-up`` and ``links-down`` options + + .. list-needs:: + :links-down: blocks, triggers + :links-up: tests, checks + + :req id=LIST-l1: Need level 1 + + :spec id=LIST-l2a: Sub-Need level 2a + :spec id=LIST-l2b: Sub-Need level 2b + + :test id=LIST-l3: Sub-Need level 3 + +``flatten`` +~~~~~~~~~~~ + +The ``flatten`` option will flatten all nested needs into a single list. + +It can be used in combination with the ``links-up`` and ``links-down`` options, +to define links by structure, without the final representation being nested. + +.. need-example:: ``flatten`` option + + .. list-needs:: + :links-down: blocks, triggers + :links-up: tests, checks + :flatten: + + :req id=LIST-f1: Need level 1 + + :spec id=LIST-f2a: Sub-Need level 2a + :spec id=LIST-f2b: Sub-Need level 2b + + :test id=LIST-f3: Sub-Need level 3 diff --git a/sphinx_needs/api/need.py b/sphinx_needs/api/need.py index 8f15379ee..ae5c12512 100644 --- a/sphinx_needs/api/need.py +++ b/sphinx_needs/api/need.py @@ -465,11 +465,16 @@ def _create_need_node( """ source = env.doc2path(data["docname"]) if data["docname"] else None - style_classes = ["need", f"need-{data['type'].lower()}"] - if data["style"]: - style_classes.append(data["style"]) - - node_need = Need("", classes=style_classes, ids=[data["id"]], refid=data["id"]) + node_need = Need( + "", + classes=[ + "need", + f"need-{data['type'].lower()}", + *([data["style"]] if data["style"] else []), + ], + ids=[data["id"]], + refid=data["id"], + ) node_need.source, node_need.line = source, data["lineno"] if data["hide"]: @@ -513,14 +518,38 @@ def _create_need_node( match_titles=False, ) - # Extract plantuml diagrams and store needumls with keys in arch, e.g. need_info['arch']['diagram'] + add_arch(data, node_need, SphinxNeedsData(env)) + + need_parts = find_parts(node_need) + update_need_with_parts(env, data, need_parts) + + SphinxNeedsData(env).set_need_node(data["id"], node_need) + + return_nodes.append(node_need) + + if post_content := data.get("post_content"): + node = nodes.Element() + with _reset_rst_titles(state): + state.nested_parse( + StringList(post_content.splitlines(), source=source), + (data["lineno"] - 1) if data["lineno"] else 0, + node, + match_titles=True, + ) + return_nodes.extend(node.children) + + return return_nodes + + +def add_arch(data: NeedsInfoType, node_need: Need, needs: SphinxNeedsData) -> None: + """Extract plantuml diagrams and store needumls with keys in arch, e.g. ``need_info['arch']['diagram']``""" data["arch"] = {} node_need_needumls_without_key = [] node_need_needumls_key_names = [] for child in node_need.children: if isinstance(child, Needuml): needuml_id = child.rawsource - if needuml := SphinxNeedsData(env).get_or_create_umls().get(needuml_id): + if needuml := needs.get_or_create_umls().get(needuml_id): try: key_name = needuml["key"] if key_name: @@ -541,27 +570,6 @@ def _create_need_node( if node_need_needumls_without_key: data["arch"]["diagram"] = node_need_needumls_without_key[0]["content"] - data["parts"] = {} - need_parts = find_parts(node_need) - update_need_with_parts(env, data, need_parts) - - SphinxNeedsData(env).set_need_node(data["id"], node_need) - - return_nodes.append(node_need) - - if post_content := data.get("post_content"): - node = nodes.Element() - with _reset_rst_titles(state): - state.nested_parse( - StringList(post_content.splitlines(), source=source), - (data["lineno"] - 1) if data["lineno"] else 0, - node, - match_titles=True, - ) - return_nodes.extend(node.children) - - return return_nodes - def del_need(app: Sphinx, need_id: str) -> None: """ diff --git a/sphinx_needs/directives/listneeds.py b/sphinx_needs/directives/listneeds.py new file mode 100644 index 000000000..bfb3d5b31 --- /dev/null +++ b/sphinx_needs/directives/listneeds.py @@ -0,0 +1,467 @@ +"""A shorthand format for specifying needs, in a field list.""" + +from __future__ import annotations + +import os +import re +from collections.abc import Sequence +from typing import Final, TypedDict + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.environment import BuildEnvironment +from sphinx.util.docutils import SphinxDirective + +from sphinx_needs.api.exceptions import InvalidNeedException +from sphinx_needs.api.need import add_arch, generate_need +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.defaults import string_to_boolean +from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.nodes import Need +from sphinx_needs.roles.need_part import find_parts, update_need_with_parts +from sphinx_needs.utils import add_doc + +LOGGER = get_logger(__name__) + + +class ListNeedsDirective(SphinxDirective): + """A shorthand format for specifying needs, in a field list.""" + + directive_name = "list-needs" + + required_arguments = 0 + optional_arguments = 0 + has_content = True + option_spec = { + "defaults": directives.unchanged_required, + "maxdepth": directives.nonnegative_int, + "flatten": directives.flag, + "links-up": directives.unchanged_required, + "links-down": directives.unchanged_required, + } + + def run(self) -> Sequence[nodes.Node]: + content = self.parse_content_to_nodes() + + if len(content) != 1 or not isinstance(content[0], nodes.field_list): + log_warning( + LOGGER, + "list-needs directive must contain exactly one field list.", + "list-needs", + location=self.get_location(), + ) + return [] + + needs_config = NeedsSphinxConfig(self.env.config) + needs_data = SphinxNeedsData(self.env) + docname = self.env.docname + doctype = os.path.splitext(self.env.doc2path(docname))[1] + + defaults: dict[str, str] = {} + if "defaults" in self.options: + try: + defaults = _field_list_to_dict(self.options["defaults"]) + except _KnownError as e: + log_warning( + LOGGER, + f"Error parsing defaults: {e}", + "list-needs", + location=self.get_location(), + ) + return [] + + links_up = [ + li.strip() + for li in self.options.get("links-up", "").split(",") + if li.strip() + ] + links_up_diff = set(links_up) - {x["option"] for x in needs_config.extra_links} + if links_up_diff: + log_warning( + LOGGER, + f"Unknown links-up link type(s): {links_up_diff}", + "list-needs", + location=self.get_location(), + ) + links_up = [] + + links_down = [ + li.strip() + for li in self.options.get("links-down", "").split(",") + if li.strip() + ] + links_down_diff = set(links_down) - { + x["option"] for x in needs_config.extra_links + } + if links_down_diff: + log_warning( + LOGGER, + f"Unknown links-down link type(s): {links_down_diff}", + "list-needs", + location=self.get_location(), + ) + links_down = [] + + return_nodes, _ = _parse_field_list( + content[0], + defaults, + needs_config, + needs_data, + self.env, + docname, + doctype, + 0, + self.options.get("maxdepth"), + "flatten" in self.options, + links_up, + links_down, + None, + ) + + add_doc(self.env, self.env.docname) + + return return_nodes + + +def _parse_field_list( + field_list: nodes.field_list, + defaults: dict[str, str], + needs_config: NeedsSphinxConfig, + needs_data: SphinxNeedsData, + env: BuildEnvironment, + docname: str, + doctype: str, + current_depth: int, + max_depth: int | None, + flatten: bool, + links_up: list[str], + links_down: list[str], + parent_id: str | None, +) -> tuple[list[nodes.Node], list[str]]: + return_nodes: list[nodes.Node] = [] + node_ids: list[str] = [] + for field_item in field_list: + if ( + not isinstance(field_item, nodes.field) + or len(field_item) != 2 + or not isinstance(field_item[0], nodes.field_name) + or not isinstance(field_item[1], nodes.field_body) + ): + log_warning( + LOGGER, + "field list does not contain the expected structure.", + "list-needs", + location=field_item, + ) + continue + + field_name: str = field_item[0].astext() + field_body: nodes.field_body = field_item[1] + + try: + kwargs = {**defaults, **_field_name_to_kwargs(field_name)} + except _KnownError as e: + log_warning( + LOGGER, + f"Error parsing field name {field_name!r} :{e}", + "list-needs", + location=field_item, + ) + continue + + try: + need_type, need_params, unknown = _kwargs_to_need_params( + kwargs, needs_config + ) + except Exception as e: + log_warning( + LOGGER, + f"Error parsing field name {field_name!r} :{e}", + "list-needs", + location=field_item, + ) + continue + + if unknown: + log_warning( + LOGGER, + f"Unknown need parameter(s): {unknown}", + "list-needs", + location=field_item, + ) + + if "title" not in need_params: + if field_body.children and isinstance( + field_body.children[0], nodes.paragraph + ): + title = field_body.children[0].rawsource + need_content = field_body.children[1:] + else: + log_warning( + LOGGER, + "Title not given and first content block is not a paragraph.", + "list-needs", + location=field_item, + ) + continue + else: + title = need_params.pop("title") + need_content = field_body.children + + try: + needs_info = generate_need( # type: ignore[misc] + needs_config, + need_type, + title, + **need_params, # type: ignore[arg-type] + docname=docname, + doctype=doctype, + lineno=field_item.line, + lineno_content=field_item.line, # note this should be the field_body line, but appears to always be None + content=field_body.rawsource, + ) + except InvalidNeedException as err: + log_warning( + LOGGER, + f"Need could not be created: {err.message}", + "list-needs", + location=field_item, + ) + continue + + if needs_data.has_need(needs_info["id"]): + if "id" not in need_params: + # this is a generated ID + message = f"Unique ID could not be generated for need with title {needs_info['title']!r}." + else: + message = f"A need with ID {needs_info['id']!r} already exists." + log_warning( + LOGGER, + message, + "list-needs", + location=field_item, + ) + continue + + if parent_id is not None: + try: + link_type = links_up[current_depth - 1] + needs_info.setdefault(link_type, []).append(parent_id) # type: ignore[misc] + except IndexError: + pass + + needs_data.add_need(needs_info) + node_ids.append(needs_info["id"]) + + # create need node + node_need = Need( + "", + classes=[ + "need", + f"need-{needs_info['type'].lower()}", + *([needs_info["style"]] if needs_info["style"] else []), + ], + ids=[needs_info["id"]], + refid=needs_info["id"], + ) + node_need.source, node_need.line = field_item.source, field_item.line + + post_nodes: list[nodes.Node] = [] + if max_depth is None or current_depth < (max_depth - 1): + for sub_node in need_content: + if isinstance(sub_node, nodes.field_list): + _nodes, _child_ids = _parse_field_list( + sub_node, + defaults, + needs_config, + needs_data, + env, + docname, + doctype, + current_depth + 1, + max_depth, + flatten, + links_up, + links_down, + needs_info["id"], + ) + if flatten: + post_nodes.extend(_nodes) + else: + node_need.extend(_nodes) + if _child_ids: + try: + link_type = links_down[current_depth] + needs_info.setdefault(link_type, []).extend(_child_ids) # type: ignore[misc] + except IndexError: + pass + else: + node_need.append(sub_node) + else: + node_need.extend(need_content) + + if needs_info["hide"]: + node_need["hidden"] = True + return_nodes.extend((node_need, *post_nodes)) + continue + + return_nodes.append( + nodes.target( + "", "", ids=[needs_info["id"]], refid=needs_info["id"], anonymous="" + ) + ) + return_nodes.extend((node_need, *post_nodes)) + + add_arch(needs_info, node_need, needs_data) + + need_parts = find_parts(node_need) + update_need_with_parts(env, needs_info, need_parts) + + needs_data.set_need_node(needs_info["id"], node_need) + + return return_nodes, node_ids + + +class _KnownError(Exception): ... + + +def _field_name_to_kwargs(field_name: str) -> dict[str, str]: + """Convert a field name to keyword arguments.""" + try: + need_type, *rest = field_name.split(maxsplit=1) + except ValueError: + raise _KnownError("no need type specified") + + if not rest: + return {"need_type": need_type} + + rest_str = rest[0] + kwargs = {} + while rest_str: + # search for the first non-space character + char, rest_str = rest_str[0], rest_str[1:] + if char in (" ", "\n", "\r", "\t"): + continue + + # the name is all chars upto a `=` or space character + name = char + has_value = False + while rest_str: + char, rest_str = rest_str[0], rest_str[1:] + if char == "=": + has_value = True + break + elif char == " ": + break + name += char + + if not has_value or not rest_str or rest_str[0] == " ": + kwargs[name] = "" + continue + + # the value can be enclosed by `"` or `'`, or is terminated by a space + quote_char, rest_str = rest_str[0], rest_str[1:] + if quote_char in ('"', "'"): + terminal_char = quote_char + value = "" + else: + value = quote_char + terminal_char = " " + while rest_str: + char, rest_str = rest_str[0], rest_str[1:] + if char == terminal_char: + break + value += char + else: + if terminal_char in ('"', "'"): + raise _KnownError("no closing quote found for value") + kwargs[name] = value + + return {"need_type": need_type, **kwargs} + + +class _NeedKwargs(TypedDict, total=False): + id: str + title: str + status: str + tags: str + collapse: bool | None + delete: bool | None + hide: bool + layout: str + style: str + constraints: str + + +def _kwargs_to_need_params( + parsed: dict[str, str], config: NeedsSphinxConfig +) -> tuple[str, _NeedKwargs, list[str]]: + """Convert keyword arguments to parameters that can be passed tp generate_need func.""" + if "need_type" not in parsed: + raise _KnownError("no need type specified") + need_type = parsed.pop("need_type") + kwargs: _NeedKwargs = {} + + # common options + if id_ := parsed.pop("id", None): + kwargs["id"] = id_ + if "title" in parsed: + kwargs["title"] = parsed.pop("title") + if status := parsed.pop("status", None): + kwargs["status"] = status + if tags := parsed.pop("tags", None): + kwargs["tags"] = tags + if "collapse" in parsed: + kwargs["collapse"] = string_to_boolean(parsed.pop("collapse")) + if "delete" in parsed: + kwargs["delete"] = string_to_boolean(parsed.pop("delete")) + if "hide" in parsed: + parsed.pop("hide") + kwargs["hide"] = True + if layout := parsed.pop("layout", None): + kwargs["layout"] = layout + if style := parsed.pop("style", None): + kwargs["style"] = style + if constraints := parsed.pop("constraints", None): + kwargs["constraints"] = constraints + + # extra options + for extra_name in config.extra_options: + if extra_name in parsed: + kwargs[extra_name] = parsed.pop(extra_name) # type: ignore[literal-required] + + # link options + for link in config.extra_links: + link_name = link["option"] + if link_name in parsed: + kwargs[link_name] = parsed.pop(link_name) # type: ignore[literal-required] + + return need_type, kwargs, list(parsed) + + +_RE_FIELD_MARKER: Final[str] = r"^:((?![: ])([^:\\]|\\.|:(?!([ `]|$)))*(? dict[str, str]: + """Parse a field list into a dict, error if invalid.""" + content_lines = content.splitlines() + defaults = {} + while content_lines: + if not content_lines[0].strip(): + content_lines.pop(0) + continue + if match := re.match(_RE_FIELD_MARKER, content_lines[0]): + name = content_lines[0][match.start(1) : match.end(1)] + body = [content_lines.pop(0)[match.end(0) :]] + while content_lines and ( + not content_lines[0] or content_lines[0].startswith(" ") + ): + body.append(content_lines.pop(0)) + smallest_indent = min( + [len(line) - len(line.lstrip()) for line in body if line.strip()] or [0] + ) + defaults[name] = "\n".join(line[smallest_indent:] for line in body) + else: + raise _KnownError(f"Invalid field list line: {content_lines[0]!r}") + return defaults diff --git a/sphinx_needs/logging.py b/sphinx_needs/logging.py index 17e1ee428..0ea59193b 100644 --- a/sphinx_needs/logging.py +++ b/sphinx_needs/logging.py @@ -39,6 +39,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "link_outgoing", "link_ref", "link_text", + "list-needs", "load_external_need", "load_service_need", "mpl", @@ -78,6 +79,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "external_link_outgoing": "Unknown outgoing link in external need", "link_ref": "Need could not be referenced", "link_text": "Reference text could not be generated", + "list-needs": "Error processing list-needs directive", "load_external_need": "Failed to load an external need", "load_service_need": "Failed to load a service need", "mpl": "Matplotlib required but not installed", diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index c684738fa..ccb37b971 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -42,6 +42,7 @@ NEEDFLOW_CONFIG_DEFAULTS, ) from sphinx_needs.directives.list2need import List2Need, List2NeedDirective +from sphinx_needs.directives.listneeds import ListNeedsDirective from sphinx_needs.directives.need import ( NeedDirective, analyse_need_locations, @@ -213,6 +214,7 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_directive("needuml", NeedumlDirective) app.add_directive("needarch", NeedarchDirective) app.add_directive("list2need", List2NeedDirective) + app.add_directive(ListNeedsDirective.directive_name, ListNeedsDirective) ######################################################################## # ROLES diff --git a/tests/__snapshots__/test_list_needs.ambr b/tests/__snapshots__/test_list_needs.ambr new file mode 100644 index 000000000..84502919f --- /dev/null +++ b/tests/__snapshots__/test_list_needs.ambr @@ -0,0 +1,934 @@ +# serializer version: 1 +# name: test_list_needs[test_app0] + dict({ + 'current_version': '', + 'versions': dict({ + '': dict({ + 'needs': dict({ + 'LIST-1a': dict({ + 'content': ''' + Need example title + + Need example on level 1. + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-1a', + 'lineno': 6, + 'section_name': 'list-needs', + 'sections': list([ + 'list-needs', + ]), + 'title': 'Need example title', + 'type': 'req', + 'type_name': 'Requirement', + }), + 'LIST-1b': dict({ + 'content': ''' + Another Need example with nested needs. + + :spec id=LIST-s2a status=open tags=list-tag1,list-tag2 author="John Doe": + Sub-Need on level 2 with other options set + :spec id=LIST-s2b title="Another Sub-Need on level 2.": + With the title given in the parameters. + + :test id=LIST-s3 collapse: Sub-Need on level 3. + + Content can contain standard *syntax*. + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-1b', + 'lineno': 9, + 'parent_needs_back': list([ + 'LIST-s2a', + 'LIST-s2b', + ]), + 'section_name': 'list-needs', + 'sections': list([ + 'list-needs', + ]), + 'title': 'Another Need example with nested needs.', + 'type': 'req', + 'type_name': 'Requirement', + }), + 'LIST-d1': dict({ + 'content': ''' + Need level 1 + + :spec id=LIST-d2 status=closed: Sub-Need level 2 + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-d1', + 'lineno': 29, + 'parent_needs_back': list([ + 'LIST-d2', + ]), + 'section_name': 'defaults', + 'sections': list([ + 'defaults', + 'list-needs', + ]), + 'status': 'open', + 'tags': list([ + 'list-tag1', + 'list-tag2', + ]), + 'title': 'Need level 1', + 'type': 'req', + 'type_name': 'Requirement', + }), + 'LIST-d2': dict({ + 'content': 'Sub-Need level 2', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-d2', + 'lineno': 31, + 'parent_need': 'LIST-d1', + 'parent_needs': list([ + 'LIST-d1', + ]), + 'section_name': 'defaults', + 'sections': list([ + 'defaults', + 'list-needs', + ]), + 'status': 'closed', + 'tags': list([ + 'list-tag1', + 'list-tag2', + ]), + 'title': 'Sub-Need level 2', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'LIST-f1': dict({ + 'blocks': list([ + 'LIST-f2a', + 'LIST-f2b', + ]), + 'content': ''' + Need level 1 + + :spec id=LIST-f2a: Sub-Need level 2a + :spec id=LIST-f2b: Sub-Need level 2b + + :test id=LIST-f3: Sub-Need level 3 + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-f1', + 'lineno': 65, + 'section_name': 'flatten', + 'sections': list([ + 'flatten', + 'list-needs', + ]), + 'tests_back': list([ + 'LIST-f2a', + 'LIST-f2b', + ]), + 'title': 'Need level 1', + 'type': 'req', + 'type_name': 'Requirement', + }), + 'LIST-f2a': dict({ + 'blocks_back': list([ + 'LIST-f1', + ]), + 'content': 'Sub-Need level 2a', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-f2a', + 'lineno': 67, + 'section_name': 'flatten', + 'sections': list([ + 'flatten', + 'list-needs', + ]), + 'tests': list([ + 'LIST-f1', + ]), + 'title': 'Sub-Need level 2a', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'LIST-f2b': dict({ + 'blocks_back': list([ + 'LIST-f1', + ]), + 'checks_back': list([ + 'LIST-f3', + ]), + 'content': ''' + Sub-Need level 2b + + :test id=LIST-f3: Sub-Need level 3 + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-f2b', + 'lineno': 68, + 'section_name': 'flatten', + 'sections': list([ + 'flatten', + 'list-needs', + ]), + 'tests': list([ + 'LIST-f1', + ]), + 'title': 'Sub-Need level 2b', + 'triggers': list([ + 'LIST-f3', + ]), + 'type': 'spec', + 'type_name': 'Specification', + }), + 'LIST-f3': dict({ + 'checks': list([ + 'LIST-f2b', + ]), + 'content': 'Sub-Need level 3', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-f3', + 'lineno': 70, + 'section_name': 'flatten', + 'sections': list([ + 'flatten', + 'list-needs', + ]), + 'title': 'Sub-Need level 3', + 'triggers_back': list([ + 'LIST-f2b', + ]), + 'type': 'test', + 'type_name': 'Test Case', + }), + 'LIST-l1': dict({ + 'blocks': list([ + 'LIST-l2a', + 'LIST-l2b', + ]), + 'content': ''' + Need level 1 + + :spec id=LIST-l2a: Sub-Need level 2a + :spec id=LIST-l2b: Sub-Need level 2b + + :test id=LIST-l3: Sub-Need level 3 + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-l1', + 'lineno': 50, + 'parent_needs_back': list([ + 'LIST-l2a', + 'LIST-l2b', + ]), + 'section_name': 'links-up and links-down', + 'sections': list([ + 'links-up and links-down', + 'list-needs', + ]), + 'tests_back': list([ + 'LIST-l2a', + 'LIST-l2b', + ]), + 'title': 'Need level 1', + 'type': 'req', + 'type_name': 'Requirement', + }), + 'LIST-l2a': dict({ + 'blocks_back': list([ + 'LIST-l1', + ]), + 'content': 'Sub-Need level 2a', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-l2a', + 'lineno': 52, + 'parent_need': 'LIST-l1', + 'parent_needs': list([ + 'LIST-l1', + ]), + 'section_name': 'links-up and links-down', + 'sections': list([ + 'links-up and links-down', + 'list-needs', + ]), + 'tests': list([ + 'LIST-l1', + ]), + 'title': 'Sub-Need level 2a', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'LIST-l2b': dict({ + 'blocks_back': list([ + 'LIST-l1', + ]), + 'checks_back': list([ + 'LIST-l3', + ]), + 'content': ''' + Sub-Need level 2b + + :test id=LIST-l3: Sub-Need level 3 + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-l2b', + 'lineno': 53, + 'parent_need': 'LIST-l1', + 'parent_needs': list([ + 'LIST-l1', + ]), + 'parent_needs_back': list([ + 'LIST-l3', + ]), + 'section_name': 'links-up and links-down', + 'sections': list([ + 'links-up and links-down', + 'list-needs', + ]), + 'tests': list([ + 'LIST-l1', + ]), + 'title': 'Sub-Need level 2b', + 'triggers': list([ + 'LIST-l3', + ]), + 'type': 'spec', + 'type_name': 'Specification', + }), + 'LIST-l3': dict({ + 'checks': list([ + 'LIST-l2b', + ]), + 'content': 'Sub-Need level 3', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-l3', + 'lineno': 55, + 'parent_need': 'LIST-l2b', + 'parent_needs': list([ + 'LIST-l2b', + ]), + 'section_name': 'links-up and links-down', + 'sections': list([ + 'links-up and links-down', + 'list-needs', + ]), + 'title': 'Sub-Need level 3', + 'triggers_back': list([ + 'LIST-l2b', + ]), + 'type': 'test', + 'type_name': 'Test Case', + }), + 'LIST-m1': dict({ + 'content': ''' + Need level 1 + + :normal: field list + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-m1', + 'lineno': 39, + 'section_name': 'maxdepth', + 'sections': list([ + 'maxdepth', + 'list-needs', + ]), + 'title': 'Need level 1', + 'type': 'req', + 'type_name': 'Requirement', + }), + 'LIST-s2a': dict({ + 'author': 'John Doe', + 'content': 'Sub-Need on level 2 with other options set', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-s2a', + 'lineno': 12, + 'parent_need': 'LIST-1b', + 'parent_needs': list([ + 'LIST-1b', + ]), + 'section_name': 'list-needs', + 'sections': list([ + 'list-needs', + ]), + 'status': 'open', + 'tags': list([ + 'list-tag1', + 'list-tag2', + ]), + 'title': 'Sub-Need on level 2 with other options set', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'LIST-s2b': dict({ + 'content': ''' + With the title given in the parameters. + + :test id=LIST-s3 collapse: Sub-Need on level 3. + + Content can contain standard *syntax*. + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-s2b', + 'lineno': 14, + 'parent_need': 'LIST-1b', + 'parent_needs': list([ + 'LIST-1b', + ]), + 'parent_needs_back': list([ + 'LIST-s3', + ]), + 'section_name': 'list-needs', + 'sections': list([ + 'list-needs', + ]), + 'title': 'Another Sub-Need on level 2.', + 'type': 'spec', + 'type_name': 'Specification', + }), + 'LIST-s3': dict({ + 'content': ''' + Sub-Need on level 3. + + Content can contain standard *syntax*. + ''', + 'docname': 'index', + 'external_css': 'external_link', + 'id': 'LIST-s3', + 'lineno': 17, + 'parent_need': 'LIST-s2b', + 'parent_needs': list([ + 'LIST-s2b', + ]), + 'section_name': 'list-needs', + 'sections': list([ + 'list-needs', + ]), + 'title': 'Sub-Need on level 3.', + 'type': 'test', + 'type_name': 'Test Case', + }), + }), + 'needs_amount': 16, + 'needs_defaults_removed': True, + 'needs_schema': dict({ + '$schema': 'http://json-schema.org/draft-07/schema#', + 'properties': dict({ + 'arch': dict({ + 'additionalProperties': dict({ + 'type': 'string', + }), + 'default': dict({ + }), + 'description': 'Mapping of uml key to uml content.', + 'field_type': 'core', + 'type': 'object', + }), + 'author': dict({ + 'default': '', + 'description': 'Added by needs_extra_options config', + 'field_type': 'extra', + 'type': 'string', + }), + 'avatar': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'blocks': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'blocks_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'checks': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'checks_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'closed_at': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'completion': dict({ + 'default': '', + 'description': 'Added for needgantt functionality', + 'field_type': 'extra', + 'type': 'string', + }), + 'constraints': dict({ + 'default': list([ + ]), + 'description': 'List of constraint names, which are defined for this need.', + 'field_type': 'core', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'constraints_error': dict({ + 'default': '', + 'description': 'An error message set if any constraint failed, and `error_message` field is set in config.', + 'field_type': 'core', + 'type': 'string', + }), + 'constraints_passed': dict({ + 'default': True, + 'description': 'True if all constraints passed, False if any failed, None if not yet checked.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'constraints_results': dict({ + 'additionalProperties': dict({ + 'type': 'object', + }), + 'default': dict({ + }), + 'description': 'Mapping of constraint name, to check name, to result.', + 'field_type': 'core', + 'type': 'object', + }), + 'content': dict({ + 'default': '', + 'description': 'Content of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'created_at': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'docname': dict({ + 'default': None, + 'description': 'Name of the document where the need is defined (None if external).', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'doctype': dict({ + 'default': '.rst', + 'description': "Type of the document where the need is defined, e.g. '.rst'.", + 'field_type': 'core', + 'type': 'string', + }), + 'duration': dict({ + 'default': '', + 'description': 'Added for needgantt functionality', + 'field_type': 'extra', + 'type': 'string', + }), + 'external_css': dict({ + 'default': '', + 'description': 'CSS class name, added to the external reference.', + 'field_type': 'core', + 'type': 'string', + }), + 'external_url': dict({ + 'default': None, + 'description': 'URL of the need, if it is an external need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'has_dead_links': dict({ + 'default': False, + 'description': 'True if any links reference need ids that are not found in the need list.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'has_forbidden_dead_links': dict({ + 'default': False, + 'description': 'True if any links reference need ids that are not found in the need list, and the link type does not allow dead links.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'id': dict({ + 'description': 'ID of the data.', + 'field_type': 'core', + 'type': 'string', + }), + 'id_prefix': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'is_external': dict({ + 'default': False, + 'description': 'If true, no node is created and need is referencing external url.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'is_modified': dict({ + 'default': False, + 'description': 'Whether the need was modified by needextend.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'is_need': dict({ + 'default': True, + 'description': 'Whether the need is a need.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'is_part': dict({ + 'default': False, + 'description': 'Whether the need is a part.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'jinja_content': dict({ + 'default': False, + 'description': 'Whether the content should be pre-processed by jinja.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'layout': dict({ + 'default': None, + 'description': 'Key of the layout, which is used to render the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'lineno': dict({ + 'default': None, + 'description': 'Line number where the need is defined (None if external).', + 'field_type': 'core', + 'type': list([ + 'integer', + 'null', + ]), + }), + 'links': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'links_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'max_amount': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'max_content_lines': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'modifications': dict({ + 'default': 0, + 'description': 'Number of modifications by needextend.', + 'field_type': 'core', + 'type': 'integer', + }), + 'params': dict({ + 'default': '', + 'description': 'Added by service open-needs', + 'field_type': 'extra', + 'type': 'string', + }), + 'parent_need': dict({ + 'default': '', + 'description': 'Simply the first parent id.', + 'field_type': 'core', + 'type': 'string', + }), + 'parent_needs': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'parent_needs_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'parts': dict({ + 'additionalProperties': dict({ + 'type': 'object', + }), + 'default': dict({ + }), + 'description': "Mapping of parts, a.k.a. sub-needs, IDs to data that overrides the need's data", + 'field_type': 'core', + 'type': 'object', + }), + 'post_content': dict({ + 'default': '', + 'description': 'Post-content of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'post_template': dict({ + 'default': None, + 'description': 'Post-template of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'pre_content': dict({ + 'default': '', + 'description': 'Pre-content of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'pre_template': dict({ + 'default': None, + 'description': 'Pre-template of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'prefix': dict({ + 'default': '', + 'description': 'Added by service open-needs', + 'field_type': 'extra', + 'type': 'string', + }), + 'query': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'section_name': dict({ + 'default': '', + 'description': 'Simply the first section.', + 'field_type': 'core', + 'type': 'string', + }), + 'sections': dict({ + 'default': list([ + ]), + 'description': 'Sections of the need.', + 'field_type': 'core', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'service': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'signature': dict({ + 'default': '', + 'description': 'Derived from a docutils desc_name node.', + 'field_type': 'core', + 'type': 'string', + }), + 'specific': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'status': dict({ + 'default': None, + 'description': 'Status of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'style': dict({ + 'default': None, + 'description': 'Comma-separated list of CSS classes (all appended by `needs_style_`).', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'tags': dict({ + 'default': list([ + ]), + 'description': 'List of tags.', + 'field_type': 'core', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'template': dict({ + 'default': None, + 'description': 'Template of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'tests': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'tests_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'title': dict({ + 'description': 'Title of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'triggers': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'triggers_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'type': dict({ + 'default': '', + 'description': 'Type of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'type_name': dict({ + 'default': '', + 'description': 'Name of the type.', + 'field_type': 'core', + 'type': 'string', + }), + 'updated_at': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'url': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'url_postfix': dict({ + 'default': '', + 'description': 'Added by service open-needs', + 'field_type': 'extra', + 'type': 'string', + }), + 'user': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + }), + 'type': 'object', + }), + }), + }), + }) +# --- diff --git a/tests/doc_test/doc_list_needs/conf.py b/tests/doc_test/doc_list_needs/conf.py new file mode 100644 index 000000000..17e09f9d6 --- /dev/null +++ b/tests/doc_test/doc_list_needs/conf.py @@ -0,0 +1,15 @@ +extensions = ["sphinx_needs"] + +needs_build_json = True +needs_json_remove_defaults = True + +needs_id_regex = "^[A-Za-z0-9_]+" + +needs_extra_options = ["author"] + +needs_extra_links = [ + {"option": "checks", "incoming": "is checked by", "outgoing": "checks"}, + {"option": "triggers", "incoming": "is triggered by", "outgoing": "triggers"}, + {"option": "blocks", "incoming": "is blocked by", "outgoing": "blocks"}, + {"option": "tests", "incoming": "is tested by", "outgoing": "tests"}, +] diff --git a/tests/doc_test/doc_list_needs/index.rst b/tests/doc_test/doc_list_needs/index.rst new file mode 100644 index 000000000..7e510b97b --- /dev/null +++ b/tests/doc_test/doc_list_needs/index.rst @@ -0,0 +1,70 @@ +list-needs +---------- + +.. list-needs:: + + :req id=LIST-1a: Need example title + + Need example on level 1. + :req id=LIST-1b: + Another Need example with nested needs. + + :spec id=LIST-s2a status=open tags=list-tag1,list-tag2 author="John Doe": + Sub-Need on level 2 with other options set + :spec id=LIST-s2b title="Another Sub-Need on level 2.": + With the title given in the parameters. + + :test id=LIST-s3 collapse: Sub-Need on level 3. + + Content can contain standard *syntax*. + +``defaults`` +~~~~~~~~~~~~ + +.. list-needs:: + :defaults: + :status: open + :tags: list-tag1,list-tag2 + + :req id=LIST-d1: Need level 1 + + :spec id=LIST-d2 status=closed: Sub-Need level 2 + +``maxdepth`` +~~~~~~~~~~~~ + +.. list-needs:: + :maxdepth: 1 + + :req id=LIST-m1: Need level 1 + + :normal: field list + +``links-up`` and ``links-down`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-needs:: + :links-down: blocks, triggers + :links-up: tests, checks + + :req id=LIST-l1: Need level 1 + + :spec id=LIST-l2a: Sub-Need level 2a + :spec id=LIST-l2b: Sub-Need level 2b + + :test id=LIST-l3: Sub-Need level 3 + +``flatten`` +~~~~~~~~~~~ + +.. list-needs:: + :links-down: blocks, triggers + :links-up: tests, checks + :flatten: + + :req id=LIST-f1: Need level 1 + + :spec id=LIST-f2a: Sub-Need level 2a + :spec id=LIST-f2b: Sub-Need level 2b + + :test id=LIST-f3: Sub-Need level 3 diff --git a/tests/test_list_needs.py b/tests/test_list_needs.py new file mode 100644 index 000000000..53bd53af7 --- /dev/null +++ b/tests/test_list_needs.py @@ -0,0 +1,22 @@ +import json +from pathlib import Path + +import pytest +from sphinx.util.console import strip_colors +from syrupy.filters import props + + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/doc_list_needs", "no_plantuml": True}], + indirect=True, +) +def test_list_needs(test_app, snapshot): + app = test_app + app.build() + + warnings = strip_colors(app._warning.getvalue()).splitlines() + assert warnings == [] + + needs = json.loads(Path(app.outdir, "needs.json").read_text("utf8")) + assert needs == snapshot(exclude=props("created", "project", "creator"))