diff --git a/.flake8 b/.flake8 index 06a4fd24e..ba265fe89 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,3 @@ [flake8] max-line-length = 120 -extend-ignore = E501, E203 \ No newline at end of file +extend-ignore = E501, E203, B028 \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b056c9967..c67b47b8a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.12.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 6.0.0 hooks: - id: flake8 additional_dependencies: @@ -15,12 +15,12 @@ repos: - pep8-naming - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.11.4 hooks: - id: isort - repo: https://github.com/asottile/pyupgrade - rev: v2.32.1 + rev: v3.3.1 hooks: - id: pyupgrade args: @@ -36,6 +36,6 @@ repos: pass_filenames: false - repo: https://github.com/python-poetry/poetry - rev: 1.2.1 + rev: 1.3.0 hooks: - id: poetry-check diff --git a/docs/directives/list2need.rst b/docs/directives/list2need.rst index e786f5d5a..6d79d268d 100644 --- a/docs/directives/list2need.rst +++ b/docs/directives/list2need.rst @@ -21,6 +21,10 @@ Need-IDs get generated automatically (hash value), if not given. IDs can be set by the prefix ``(ID)`` in the line. Example: ``(REQ-1)My first requirement``. This mechanism is the same as the one used by :ref:`need_part`. +Options for the need-objects can be set by adding them like ``((status="open"))``. +For details please see :ref:`list2need_meta_data`. + + .. code-block:: rst .. list2need:: @@ -30,12 +34,13 @@ This mechanism is the same as the one used by :ref:`need_part`. * Need example on level 1 * (NEED-002) Another Need example on level 1 with a given ID - * Sub-Need on level 2 + * Sub-Need on level 2 with status option set * Another Sub-Need on level 2. Where this sentence will be used as content, the first one as title. * Sub-Need on level 3. With some rst-syntax support for the **content** by :ref:`list2need` + .. list2need:: :types: req, spec, test :presentation: nested @@ -43,7 +48,7 @@ This mechanism is the same as the one used by :ref:`need_part`. * Need example on level 1 * (NEED-002) Another Need example on level 1 with a given ID - * Sub-Need on level 2 + * Sub-Need on level 2 with status option set ((status='open')) * Another Sub-Need on level 2. Where this sentence will be used as content, the first one as title. * Sub-Need on level 3. With some rst-syntax support for @@ -69,6 +74,26 @@ So it can be used to structure longer titles or content, and has no impact on th Options ------- +types +~~~~~ + +List of need-types, which are used for the different list-levels. +As input name the ``directive`` entry from the configuration variable :ref:`needs_types` is used. + +There is no default value and ``types`` must be set. + +.. code-block:: rst + + .. list2need:: + :types: feature, function, test + + * Login user + * Provide login screen + * Create password hash + * Recalculate hash and compare + + + presentation ~~~~~~~~~~~~ Defines how the single Sphinx-Needs objects shall be presented. @@ -89,6 +114,41 @@ The first split part is used as title, the rest as content. Default: **.** +links-down +~~~~~~~~~~ +``links-down`` set automatically links between the different levels of the list. + +.. code-block:: rst + + .. list2need:: + :types: req, spec, test + :presentation: standalone + :links-down: triggers, tests + + * (NEED-A)Login user + * (NEED-B)Provide login screen + * (NEED-C)Create password hash + * (NEED-D)Recalculate hash and compare + +``:links-down: triggers, tests`` will set a link from type ``triggers`` from ``NEED-A`` to ``NEED-B`` and ``NEED-C``. +``NEED-C`` will get a link from type ``tests`` to ``NEED-D``. + +So links get set from the upper level down to all need-objects on the direct lower level (top-down approach). + +The amount of given link-types must be the amount of used levels minus 1. + +**Result from the above example**: + +.. list2need:: + :types: req, spec, test + :presentation: standalone + :links-down: triggers, tests + + * (NEED-A)Login user + * (NEED-B)Provide login screen + * (NEED-C)Create password hash + * (NEED-D)Recalculate hash and compare + List examples ------------- @@ -224,55 +284,44 @@ Lists with need-part support * And a spec need. Lets reference a need-part frm above: :need:`LIST2NEED-REQ-1.1` +.. _list2need_meta_data: + Set meta-data ~~~~~~~~~~~~~ -To set also meta-data for selected needs created by :ref:`list2need`, you can use -:ref:`needextend` in a second step. +Meta-data can be set directly in the related line via: ``((status="open"))``. +Or if the amount of option/values is getting too complex, in a second step +by using :ref:`needextend`. + +The position of the option-string inside the line is not important. +Multiple options need to be separated by ``,``. +And instead of ``"`` also ``'`` can be used. .. code-block:: rst .. list2need:: :types: feature, req - * (EXT-FEATURE-A)Feature A - * (EXT-REQ-1)Requirement 1. It shall be fast. - * (EXT-REQ-2)Requirement 2. It shall be big. - * (EXT-FEATURE-B)Feature B - - - .. needextend:: EXT-REQ-1 - :status: closed - :style: green_border + * (EXT-FEATURE-A)Feature A + * (EXT-REQ-1)Requirement 1. It shall be fast. ((tags="A, fast", style="green_border")) + * (EXT-REQ-2)Requirement 2. It shall be big. ((tags="A, big", style="red_border")) + * (EXT-FEATURE-B)Feature B. + Options are given in next line for readability + ((status="done", tags="B", links="EXT-FEATURE-A")) - .. needextend:: EXT-REQ-2 - :status: open - :style: red_border + .. needextend:: EXT-FEATURE-B + :style: yellow - .. needextend:: id in ["EXT-FEATURE-A", "EXT-FEATURE-B"] - :tags: fast, big - .. needextend:: EXT-FEATURE-B - :links: EXT-FEATURE-A .. list2need:: :types: feature, req * (EXT-FEATURE-A)Feature A - * (EXT-REQ-1)Requirement 1. It shall be fast. - * (EXT-REQ-2)Requirement 2. It shall be big. - * (EXT-FEATURE-B)Feature B - - -.. needextend:: EXT-REQ-1 - :status: closed - :style: green_border - -.. needextend:: EXT-REQ-2 - :status: open - :style: red_border - -.. needextend:: id in ["EXT-FEATURE-A", "EXT-FEATURE-B"] - :tags: fast, big + * (EXT-REQ-1)Requirement 1. It shall be fast. ((tags="A, fast", style="green_border")) + * (EXT-REQ-2)Requirement 2. It shall be big. ((tags="A, big", style="red_border")) + * (EXT-FEATURE-B)Feature B. + Options are given in next line for readability + ((status="done", tags="B", links="EXT-FEATURE-A")) .. needextend:: EXT-FEATURE-B - :links: EXT-FEATURE-A \ No newline at end of file + :style: yellow \ No newline at end of file diff --git a/sphinx_needs/directives/list2need.py b/sphinx_needs/directives/list2need.py index ea9edba1c..bb4a21982 100644 --- a/sphinx_needs/directives/list2need.py +++ b/sphinx_needs/directives/list2need.py @@ -1,6 +1,7 @@ +import hashlib import re from contextlib import suppress -from typing import Any, Sequence +from typing import Any, List, Sequence from docutils import nodes from docutils.parsers.rst import Directive, directives @@ -9,11 +10,19 @@ NEED_TEMPLATE = """.. {{type}}:: {{title}} {% if need_id is not none %}:id: {{need_id}}{%endif%} + {% if set_links_down %}:{{links_down_type}}: {{ links_down|join(', ') }}{%endif%} + {%- for name, value in options.items() %}:{{name}}: {{value}} + {% endfor %} {{content}} """ +LINE_REGEX = re.compile(r"(?P[^\S\n]*)\*\s*(?P.*)|[\S\*]*(?P.*)") +ID_REGEX = re.compile(r"(\((?P[^\"'=\n]+)?\))") # Exclude some chars, which are used by option list +OPTION_AREA_REGEX = re.compile(r"\(\((.*)\)\)") +OPTIONS_REGEX = re.compile(r"([^=,\s]*)=[\"']([^\"]*)[\"']") + class List2Need(nodes.General, nodes.Element): # type: ignore pass @@ -41,6 +50,7 @@ def presentation(argument: str) -> Any: "types": directives.unchanged, "delimiter": directives.unchanged, "presentation": directives.unchanged, + "links-down": directives.unchanged, } def run(self) -> Sequence[nodes.Node]: @@ -56,56 +66,78 @@ def run(self) -> Sequence[nodes.Node]: if not delimiter: delimiter = "." - # line = re.compile(r"(?P[^\S\n]*)\*\s*(?P.*)") - # line = re.compile(r"(?P[^\S\n]*)\*\s*(?P.*)|(?P.*)") - line = re.compile(r"(?P[^\S\n]*)\*\s*(?P.*)|[\S\*]*(?P.*)") - id_regex = re.compile(r"(\((?P.*)?\))") content_raw = "\n".join(self.content) types_raw = self.options.get("types") if not types_raw: raise SphinxWarning("types must be set.") - # Create a dict, which delivers the need-type for the later level types = {} types_raw_list = [x.strip() for x in types_raw.split(",")] - conf_types = [x["directive"] for x in env.config.needs_types] for x in range(0, len(types_raw_list)): types[x] = types_raw_list[x] if types[x] not in conf_types: raise SphinxError(f"Unknown type configured: {types[x]}. Allowed are {', '.join(conf_types)}") + down_links_raw = self.options.get("links-down") + if down_links_raw is None or down_links_raw == "": + down_links_raw = "" + + # Create a dict, which delivers the need-link for the later level + down_links_types = {} + if down_links_raw is None or down_links_raw == "": + down_links_raw_list = [] + else: + down_links_raw_list = [x.strip() for x in down_links_raw.split(",")] + link_types = [x["option"] for x in env.config.needs_extra_links] + for i, down_link_raw in enumerate(down_links_raw_list): + down_links_types[i] = down_link_raw + if down_link_raw not in link_types: + raise SphinxError(f"Unknown link configured: {down_link_raw}. " f"Allowed are {', '.join(link_types)}") list_needs = [] # Storing the data in a sorted list for content_line in content_raw.split("\n"): # for groups in line.findall(content_raw): - match = line.search(content_line) + match = LINE_REGEX.search(content_line) if not match: continue indent, text, more_text = match.groups() if text: - need_id_result = id_regex.search(text) - if need_id_result: - need_id = need_id_result.group(2) - text = id_regex.sub("", text) - else: - need_id = None + indent = len(indent) + if not indent % 2 == 0: + raise IndentationError("Indentation for list must be always a multiply of 2.") + level = int(indent / 2) + + if level not in types: + raise SphinxWarning( + f"No need type defined for indentation level {level}." f" Defined types {types}" + ) + + if down_links_types and level > len(down_links_types): + raise SphinxWarning(f"Not enough links-down defined for indentation level {level}.") splitted_text = text.split(delimiter) title = splitted_text[0] + content = "" with suppress(IndexError): content = delimiter.join(splitted_text[1:]) # Put the content together again - indent = len(indent) - if not indent % 2 == 0: - raise IndentationError("Indentation for list must be always a multiply of 2.") - - level = int(indent / 2) + need_id_result = ID_REGEX.search(title) + if need_id_result: + need_id = need_id_result.group(2) + title = ID_REGEX.sub("", title) + else: + # Calculate the hash value, so that we can later reuse it + prefix = "" + needs_id_length = env.config.needs_id_length + for need_type in env.config.needs_types: + if need_type["directive"] == types[level]: + prefix = need_type["prefix"] + break - if level not in types: - raise SphinxWarning(f"No need type defined for identtion level {level}." f" Defined types {types}") + need_id = self.make_hashed_id(prefix, title, needs_id_length) need = { "title": title, @@ -113,6 +145,7 @@ def run(self) -> Sequence[nodes.Node]: "type": types[level], "content": content.lstrip(), "level": level, + "options": {}, } list_needs.append(need) else: @@ -123,8 +156,30 @@ def run(self) -> Sequence[nodes.Node]: # Finally creating the rst code overall_text = [] - for list_need in list_needs: + for index, list_need in enumerate(list_needs): + # Search for meta data in the complete title/content + search_string = list_need["title"] + list_need["content"] + result = OPTION_AREA_REGEX.search(search_string) + if result is not None: # An option was found + option_str = result.group(1) # We only deal with the first finding + option_result = OPTIONS_REGEX.findall(option_str) + list_need["options"] = {x[0]: x[1] for x in option_result} + + # Remove possible option-strings from title and content + list_need["title"] = OPTION_AREA_REGEX.sub("", list_need["title"]) + list_need["content"] = OPTION_AREA_REGEX.sub("", list_need["content"]) + template = Template(NEED_TEMPLATE, autoescape=True) + + data = list_need + need_links_down = self.get_down_needs(list_needs, index) + if down_links_types and list_need["level"] in down_links_types and need_links_down: + data["links_down"] = need_links_down + data["links_down_type"] = down_links_types[list_need["level"]] + data["set_links_down"] = True + else: + data["set_links_down"] = False + text = template.render(**list_need) text_list = text.split("\n") if presentation == "nested": @@ -135,3 +190,37 @@ def run(self) -> Sequence[nodes.Node]: self.state_machine.insert_input(overall_text, self.state_machine.document.attributes["source"]) return [] + + def make_hashed_id(self, type_prefix: str, title: str, id_length: int) -> str: + hashable_content = title + return "{}{}".format( + type_prefix, hashlib.sha1(hashable_content.encode("UTF-8")).hexdigest().upper()[:id_length] + ) + + def get_down_needs(self, list_needs: List[Any], index: int) -> List[str]: + """ + Return all needs which are directly under the one given by the index + """ + current_level = list_needs[index]["level"] + + down_links = [] + next_index = index + 1 + try: + next_need = list_needs[next_index] + except IndexError: + return [] + + while next_need: + if next_need["level"] == current_level + 1: + down_links.append(next_need["need_id"]) + + if next_need["level"] == current_level: + break # No further needs below this need + + next_index += 1 + try: + next_need = list_needs[next_index] + except IndexError: + next_need = None + + return down_links diff --git a/tests/doc_test/doc_list2need/conf.py b/tests/doc_test/doc_list2need/conf.py index b2f717c17..79b21b25a 100644 --- a/tests/doc_test/doc_list2need/conf.py +++ b/tests/doc_test/doc_list2need/conf.py @@ -43,6 +43,11 @@ {"directive": "test", "title": "Test Case", "prefix": "TC_", "color": "#DCB239", "style": "node"}, ] +needs_extra_links = [ + {"option": "checks", "incoming": "is checked by", "outgoing": "checks"}, + {"option": "triggers", "incoming": "is triggered by", "outgoing": "triggers"}, +] + plantuml = "java -jar %s" % os.path.join(os.path.dirname(__file__), "..", "utils", "plantuml.jar") plantuml_output_format = "svg" diff --git a/tests/doc_test/doc_list2need/index.rst b/tests/doc_test/doc_list2need/index.rst index a261dccb4..bd56d3c56 100644 --- a/tests/doc_test/doc_list2need/index.rst +++ b/tests/doc_test/doc_list2need/index.rst @@ -21,4 +21,10 @@ TEST DOCUMENT LIST2NEED Test chapter ------------ -Foo Bar. \ No newline at end of file +Foo Bar. + + +.. toctree:: + + options + links_down \ No newline at end of file diff --git a/tests/doc_test/doc_list2need/links_down.rst b/tests/doc_test/doc_list2need/links_down.rst new file mode 100644 index 000000000..619617171 --- /dev/null +++ b/tests/doc_test/doc_list2need/links_down.rst @@ -0,0 +1,17 @@ +LIST2NEED LINKS_DOWN +==================== + + +.. list2need:: + :types: story, spec, test + :links-down: checks, triggers, links + :presentation: standalone + :delimiter: . + + * (NEED-A)Need example on level 1 + * (NEED-B)Need example on level 2 + * (NEED-C)Need example on level 3 + + + + diff --git a/tests/doc_test/doc_list2need/options.rst b/tests/doc_test/doc_list2need/options.rst new file mode 100644 index 000000000..1f6884299 --- /dev/null +++ b/tests/doc_test/doc_list2need/options.rst @@ -0,0 +1,18 @@ +LIST2NEED OPTIONS +================= + + +.. list2need:: + :types: story, spec, test + :presentation: nested + :delimiter: . + + * (NEED-1)Need example on level 1 ((status="open")) + * (NEED-2)Need example on level 1 ((status="done", tags="tag1, tag2, tag3")) + * (NEED-3)Link example ((links="NEED-1, NEED-2")) + * (NEED-4)New line example. + With some content in the next line. + ((status="in progress", links="NEED-3")) + + + diff --git a/tests/test_list2need.py b/tests/test_list2need.py index 977e5719f..0569c32a9 100644 --- a/tests/test_list2need.py +++ b/tests/test_list2need.py @@ -17,3 +17,39 @@ def test_doc_list2need_html(test_app): 'NEED-002' in index_html ) + + options_html = Path(app.outdir, "options.html").read_text() + + assert 'status: ' in options_html + assert 'done' in options_html + assert 'in progress' in options_html + + # check for 2 links + assert ( + '
links outgoing: NEED-1, NEED-2' + "
" in options_html + ) + + # check for option defined in own, new line + assert ( + '
links outgoing: NEED-3
' in options_html + ) + + links_down_html = Path(app.outdir, "links_down.html").read_text() + + assert ( + '
checks: NEED-B
' in links_down_html + ) + + assert ( + '
triggers: NEED-C
' in links_down_html + ) + + assert ( + '
is triggered by: NEED-B
' in links_down_html + )