From 934a3a0e0034f89e4219c994d693ebac02e97918 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 21 May 2024 13:05:26 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=8C=20Improve=20need-extend;=20allow?= =?UTF-8?q?=20dynamic=20functions=20in=20lists=20(#1076)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow for lists containing dynamic functions, for example ```restructuredtext .. needextend:: REQ_1 :links: REQ_1, [[func("arg")]], REQ_2 ``` --- sphinx_needs/directives/needextend.py | 102 +++++++++--------- tests/__snapshots__/test_needextend.ambr | 70 +++++++++++- .../doc_test/doc_needextend_dynamic/index.rst | 7 +- tests/test_needextend.py | 25 +++++ 4 files changed, 152 insertions(+), 52 deletions(-) diff --git a/sphinx_needs/directives/needextend.py b/sphinx_needs/directives/needextend.py index 7c61ddfd4..8dd10debd 100644 --- a/sphinx_needs/directives/needextend.py +++ b/sphinx_needs/directives/needextend.py @@ -70,6 +70,29 @@ def run(self) -> Sequence[nodes.Node]: return [targetnode, Needextend("")] +RE_ID_FUNC = re.compile(r"\s*((?P\[\[[^\]]*\]\])|(?P[^;,]+))\s*([;,]|$)") +"""Regex to find IDs or functions, delimited by one of `;|,`.""" + + +def _split_value(value: str) -> Sequence[tuple[str, bool]]: + """Split a string into a list of values. + + The string is split on `;`/`,` and whitespace is removed from the start and end of each value. + If a value starts with `[[` and ends with `]]`, it is considered a function. + + :return: A list of tuples, where the first item is the value and the second is True if the value is a function. + """ + if "[[" in value: + # may contain dynamic functions + return [ + (m.group("function"), True) + if m.group("function") + else (m.group("id").strip(), False) + for m in RE_ID_FUNC.finditer(value) + ] + return [(i.strip(), False) for i in re.split(";|,", value) if i.strip()] + + def extend_needs_data( all_needs: dict[str, NeedsInfoType], extends: dict[str, NeedsExtendType], @@ -137,33 +160,23 @@ def extend_needs_data( if option.startswith("+"): option_name = option[1:] if option_name in link_names: - if value.strip().startswith("[[") and value.strip().endswith( - "]]" - ): # dynamic function - need[option_name].append(value) - else: - for ref_need in [i.strip() for i in re.split(";|,", value)]: - if ref_need not in all_needs: - logger.warning( - f"Provided link id {ref_need} for needextend does not exist. [needs]", - type="needs", - location=( - current_needextend["docname"], - current_needextend["lineno"], - ), - ) - continue - if ref_need not in need[option_name]: - need[option_name].append(ref_need) + for item, is_function in _split_value(value): + if (not is_function) and (item not in all_needs): + logger.warning( + f"Provided link id {item} for needextend does not exist. [needs]", + type="needs", + location=( + current_needextend["docname"], + current_needextend["lineno"], + ), + ) + continue + if item not in need[option_name]: + need[option_name].append(item) elif option_name in list_values: - if value.strip().startswith("[[") and value.strip().endswith( - "]]" - ): # dynamic function - need[option_name].append(value) - else: - for item in [i.strip() for i in re.split(";|,", value)]: - if item not in need[option_name]: - need[option_name].append(item) + for item, _is_function in _split_value(value): + if item not in need[option_name]: + need[option_name].append(item) else: if need[option_name]: # If content is already stored, we need to add some whitespace @@ -181,29 +194,20 @@ def extend_needs_data( else: if option in link_names: need[option] = [] - if value.strip().startswith("[[") and value.strip().endswith( - "]]" - ): # dynamic function - need[option].append(value) - else: - for ref_need in [i.strip() for i in re.split(";|,", value)]: - if ref_need not in all_needs: - logger.warning( - f"Provided link id {ref_need} for needextend does not exist. [needs]", - type="needs", - location=( - current_needextend["docname"], - current_needextend["lineno"], - ), - ) - continue - need[option].append(ref_need) + for item, is_function in _split_value(value): + if (not is_function) and (item not in all_needs): + logger.warning( + f"Provided link id {item} for needextend does not exist. [needs]", + type="needs", + location=( + current_needextend["docname"], + current_needextend["lineno"], + ), + ) + continue + need[option].append(item) elif option in list_values: - if value.strip().startswith("[[") and value.strip().endswith( - "]]" - ): # dynamic function - need[option].append(value) - else: - need[option] = [i.strip() for i in re.split(";|,", value)] + for item, _is_function in _split_value(value): + need[option].append(item) else: need[option] = value diff --git a/tests/__snapshots__/test_needextend.ambr b/tests/__snapshots__/test_needextend.ambr index fd3df3987..65a4e846f 100644 --- a/tests/__snapshots__/test_needextend.ambr +++ b/tests/__snapshots__/test_needextend.ambr @@ -41,6 +41,7 @@ 'layout': '', 'links': list([ 'REQ_A_1', + 'REQ_D_1', 'REQ_B_1', ]), 'max_amount': '', @@ -278,8 +279,75 @@ 'url_postfix': '', 'user': '', }), + 'REQ_D_1': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + ]), + 'constraints_passed': True, + 'constraints_results': dict({ + }), + 'content_id': 'REQ_D_1', + 'created_at': '', + 'delete': None, + 'description': '', + 'docname': 'index', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'Requirement D 1', + 'has_dead_links': False, + 'has_forbidden_dead_links': False, + 'id': 'REQ_D_1', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'needextend dynamic functions', + 'sections': list([ + 'needextend dynamic functions', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': None, + 'style': None, + 'tags': list([ + ]), + 'target_id': 'REQ_D_1', + 'template': None, + 'title': 'Requirement D 1', + 'type': 'req', + 'type_name': 'Requirement', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), }), - 'needs_amount': 4, + 'needs_amount': 5, }), }), }) diff --git a/tests/doc_test/doc_needextend_dynamic/index.rst b/tests/doc_test/doc_needextend_dynamic/index.rst index 17ba8911c..0c150ae08 100644 --- a/tests/doc_test/doc_needextend_dynamic/index.rst +++ b/tests/doc_test/doc_needextend_dynamic/index.rst @@ -13,8 +13,11 @@ needextend dynamic functions .. req:: Requirement C 1 :id: REQ_C_1 +.. req:: Requirement D 1 + :id: REQ_D_1 + .. needextend:: REQ_1 - :links: [[get_matching_need_ids("REQ_A_")]] + :links: [[get_matching_need_ids("REQ_A_")]];REQ_D_1 .. needextend:: REQ_1 - :+links: [[get_matching_need_ids("REQ_B_")]] + :+links: REQ_D_1 , [[get_matching_need_ids("REQ_B_")]] diff --git a/tests/test_needextend.py b/tests/test_needextend.py index 8f5bde96b..81aa6602a 100644 --- a/tests/test_needextend.py +++ b/tests/test_needextend.py @@ -6,6 +6,8 @@ from sphinx.util.console import strip_colors from syrupy.filters import props +from sphinx_needs.directives.needextend import _split_value + @pytest.mark.parametrize( "test_app", @@ -64,6 +66,29 @@ def test_doc_needextend_unknown_id(test_app: Sphinx): ] +@pytest.mark.parametrize( + "value,expected", + [ + ("a", [("a", False)]), + ("a,", [("a", False)]), + ("[[a]]", [("[[a]]", True)]), + ("[[a]]b", [("[[a]]b", False)]), + ("[[a;]],", [("[[a;]]", True)]), + ("a,b;c", [("a", False), ("b", False), ("c", False)]), + ("[[a]],[[b]];[[c]]", [("[[a]]", True), ("[[b]]", True), ("[[c]]", True)]), + (" a ,, b; c ", [("a", False), ("b", False), ("c", False)]), + ( + " [[a]] ,, [[b]] ; [[c]] ", + [("[[a]]", True), ("[[b]]", True), ("[[c]]", True)], + ), + ("a,[[b]];c", [("a", False), ("[[b]]", True), ("c", False)]), + (" a ,, [[b;]] ; c ", [("a", False), ("[[b;]]", True), ("c", False)]), + ], +) +def test_split_value(value, expected): + assert _split_value(value) == expected + + @pytest.mark.parametrize( "test_app", [{"buildername": "html", "srcdir": "doc_test/doc_needextend_dynamic"}],