diff --git a/docs/how-to/write_docs.rst b/docs/how-to/write_docs.rst index c1daaf191..68f43676d 100644 --- a/docs/how-to/write_docs.rst +++ b/docs/how-to/write_docs.rst @@ -15,6 +15,9 @@ Write Documentation =================== +Outside Documentation +^^^^^^^^^^^^^^^^^^^^^ + `Sphinx `_: the documentation generator we use. @@ -23,3 +26,47 @@ the plain-text markup language used for most source files in this project. `Sphinx-Needs `_: a Sphinx extension that models requirements, tests, tasks and other "needs" inside the docs. + + +Advanced +^^^^^^^^ + +Needextend +~~~~~~~~~~ + +Needextend allows you to extend needs that are defined in the documentation. +The scope of allowed behaviour in Docs-As-Code for needextend is limited as we do not allow all of its usecases. + +Allowed usecases: + +* Setting an attribute or Link **IF** it is not already set in the need that is getting modified +* Appending to a list of links + +Not Allowed: + +* Overwriting an attribute or Link that already is set in the need that gets modified +* Deleting an attribute or Link + +.. code-block:: rst + + .. stkh_req:: Test Req Extends 1 + :id: stkh_req__test__need_extends_1 + :status: invalid + + + # ✅ ALLOWED => The replacing of attributes on needs that are NOT set. + .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :safety: NO + + + # ❌ NOT ALLOWED => Overwriting attributes that are already set in the need + .. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :status: valid + + +For further documentation on needextends please `look here `_ + +.. note:: + + In the future we will enable a check that needextends will only modify needs in the current document. + You can ensure this by adding `c.this_doc()` to the filter string of the need. diff --git a/src/extensions/score_metamodel/BUILD b/src/extensions/score_metamodel/BUILD index 82fe3eaed..3ddd80e15 100644 --- a/src/extensions/score_metamodel/BUILD +++ b/src/extensions/score_metamodel/BUILD @@ -60,9 +60,23 @@ py_library( ) score_pytest( - name = "score_metamodel_tests", + name = "unit_tests", size = "small", - srcs = glob(["tests/*.py"]), + srcs = glob( + [ + "tests/*.py", + ], + exclude = ["tests/test_rules_file_based.py"], + ), + data = ["tests/model/simple_model.yaml"], + pytest_config = "//:pyproject.toml", + deps = [":score_metamodel"], +) + +score_pytest( + name = "file_based_tests", + size = "medium", + srcs = ["tests/test_rules_file_based.py"], # All requirements already in the library so no need to have it double data = glob( [ @@ -70,6 +84,6 @@ score_pytest( "tests/**/*.yaml", ], ) + ["tests/rst/conf.py"], - deps = [":score_metamodel"], pytest_config = "//:pyproject.toml", + deps = [":score_metamodel"], ) diff --git a/src/extensions/score_metamodel/checks/check_needs_extends.py b/src/extensions/score_metamodel/checks/check_needs_extends.py new file mode 100644 index 000000000..f4c79bc65 --- /dev/null +++ b/src/extensions/score_metamodel/checks/check_needs_extends.py @@ -0,0 +1,142 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from __future__ import annotations + +import sphinx_needs.directives.need +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import ExtendType, NeedsExtendType, NeedsMutable +from sphinx_needs.directives.needextend import extend_needs_data as original_function +from sphinx_needs.exceptions import NeedsInvalidFilter +from sphinx_needs.filter_common import filter_needs_mutable +from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.needs_schema import ( + FieldFunctionArray, + FieldLiteralValue, + LinksFunctionArray, + LinksLiteralValue, +) + +logger = get_logger(__name__) + + +def score_extend_needs_data_func( # noqa: C901 + all_needs: NeedsMutable, + extends: dict[str, NeedsExtendType], + needs_config: NeedsSphinxConfig, +): + """Use data gathered from needextend directives to modify fields of existing needs.""" + # regardless of parallel build worker completion order. + sorted_extends = sorted(extends.values(), key=lambda x: (x["docname"], x["lineno"])) + + current_needextend: NeedsExtendType + for current_needextend in sorted_extends: + need_filter = current_needextend["filter"] + location = (current_needextend["docname"], current_needextend["lineno"]) + + # ╓ ╖ + # ║ This is currently as a grace period still allowed, but ║ + # ║ will be forbiden in future releases ║ + # ╙ ╜ + # if "c.this_doc()" not in need_filter: + # error_msg = "Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document" + # log_warning(logger, error_msg, "needextend", location=location) + + if current_needextend["filter_is_id"]: + try: + found_needs = [all_needs[need_filter]] + except KeyError: + error = f"Provided id {need_filter!r} for needextend does not exist." + if current_needextend["strict"]: + raise NeedsInvalidFilter(error) from KeyError + log_warning(logger, error, "needextend", location=location) + continue + else: + try: + found_needs = filter_needs_mutable( + all_needs, + needs_config, + need_filter, + location=location, + origin_docname=current_needextend["docname"], + ) + except Exception as e: + log_warning( + logger, + f"Invalid filter {need_filter!r}: {e}", + "needextend", + location=location, + ) + continue + for found_need in found_needs: + if found_need["is_external"]: + log_warning( + logger, + f"Error when extending need: {found_need['id']}. " + + "It is not allowed to modify external needs via needextend", + "needextend", + location, + ) + # Work in the stored needs, not on the search result + need = all_needs[found_need["id"]] + + location = ( + current_needextend["docname"], + current_needextend["lineno"], + ) + + for _, etype, link_value in current_needextend["list_modifications"]: + match (etype, link_value): + case ( + ExtendType.REPLACE | ExtendType.DELETE, + LinksLiteralValue() | LinksFunctionArray(), + ): + # Replacing / Deleting links is not allowed + error_msg = ( + f"Error when extending need: {need['id']}. " + "Replace or Delete action is not allowed via needextends." + ) + # logger.warning_for_need(current_needextend["id"], error_msg) + log_warning(logger, error_msg, "needextend", location=location) + + for option_name, etype, field_value in current_needextend["modifications"]: + if etype == ExtendType.DELETE: + error_msg = ( + f"Error when extending need: {need['id']}. " + "Delete action is not allowed via needextends." + ) + log_warning(logger, error_msg, "needextend", location=location) + match (etype, field_value): + case (ExtendType.APPEND, FieldLiteralValue()): + if isinstance(field_value.value, str): + error_msg = ( + f"Error when extending need: {need['id']}. " + "Append action is not allowed via needextends on 'string type options'." + ) + log_warning( + logger, error_msg, "needextend", location=location + ) + + case ( + ExtendType.REPLACE, + None | FieldLiteralValue() | FieldFunctionArray(), + ): + if need[option_name]: + error_msg = f"Error when extending need: {need['id']}. Replacing of options that are already set is not allowed via needextends." + + log_warning( + logger, error_msg, "needextend", location=location + ) + return original_function(all_needs, extends, needs_config) + + +sphinx_needs.directives.need.extend_needs_data = score_extend_needs_data_func diff --git a/src/extensions/score_metamodel/tests/rst/conf.py b/src/extensions/score_metamodel/tests/rst/conf.py index e68ed77cd..180333964 100644 --- a/src/extensions/score_metamodel/tests/rst/conf.py +++ b/src/extensions/score_metamodel/tests/rst/conf.py @@ -27,3 +27,7 @@ "json_url": "https://eclipse-score.github.io/process_description/main/needs.json", } ] +# We add these suppress_warnings here to ease the load of the warnings +# In the future we might want to check if ANY warnings comes in the document +# And then ensure that we error, as this could also be parsing errors etc. +suppress_warnings = ["app.add_directive", "app.add_node", "app.add_role"] diff --git a/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst new file mode 100644 index 000000000..10fb3f0c8 --- /dev/null +++ b/src/extensions/score_metamodel/tests/rst/options/test_need_extends.rst @@ -0,0 +1,75 @@ +.. + # ******************************************************************************* + # Copyright (c) 2026 Contributors to the Eclipse Foundation + # + # See the NOTICE file(s) distributed with this work for additional + # information regarding copyright ownership. + # + # This program and the accompanying materials are made available under the + # terms of the Apache License Version 2.0 which is available at + # https://www.apache.org/licenses/LICENSE-2.0 + # + # SPDX-License-Identifier: Apache-2.0 + # ******************************************************************************* + +.. stkh_req:: Test Req Extends 1 + :id: stkh_req__test__need_extends_1 + :status: invalid + + +.. stkh_req:: Test Req Extends 2 + :id: stkh_req__test__need_extends_abc + :status: valid + + +.. feat_req:: Test Linkage Override + :id: feat_req__test__linkage_override + :satisfies: stkh_req__test__need_extends_1 + + +.. Replacing of options that are already set is not allowed. + +#EXPECT: Error when extending need: stkh_req__test__need_extends_1. Replacing of options that are already set is not allowed via needextends. + +.. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :status: valid + + +.. We explicitly allow the replacing of options on needs that are NOT set and +.. where the need is in the current document + +#EXPECT-NOT: Replacing of options + +.. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :safety: NO + + +#EXPECT-NOT: Replacing of options + +.. needextend:: c.this_doc() and id == 'stkh_req__test__need_extends_1' + :safety: NO + + +#EXPECT: Error when extending need: feat_req__test__linkage_override. Replace or Delete action is not allowed via needextends. + +.. needextend:: feat_req__test__linkage_override + :satisfies: stkh_req__test__need_extends_abc + + +#EXPECT: Error when extending need: stkh_req__test__need_extends_1. Delete action is not allowed via needextends. + +.. needextend:: id == 'stkh_req__test__need_extends_1' + :-safety: + + +#EXPECT: Error when extending need: stkh_req__test__need_extends_1. Append action is not allowed via needextends on 'string type options' + +.. needextend:: id == 'stkh_req__test__need_extends_1' + :+safety: YES + + +.. This will be activated once we have activated the c.this_doc() check aswell +.. #EXPECT: Potentially altering needs outside of the document is not allowed. Please add 'c.this_doc()' to the needextend to limit it to only needs in the same document + +.. .. needextend:: id == 'stkh_req__test__need_extends_1' +.. :security: QM diff --git a/src/extensions/score_metamodel/tests/test_rules_file_based.py b/src/extensions/score_metamodel/tests/test_rules_file_based.py index 73a65962c..bc221d5dd 100644 --- a/src/extensions/score_metamodel/tests/test_rules_file_based.py +++ b/src/extensions/score_metamodel/tests/test_rules_file_based.py @@ -36,12 +36,6 @@ def sphinx_base_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: ### Create a temporary directory for Sphinx and copy all necessary files. base_dir: Path = tmp_path_factory.mktemp("docs") shutil.copy(RST_DIR / "conf.py", base_dir) - shutil.copytree( - DOCS_DIR / TOOLING_DIR_NAME, - base_dir / TOOLING_DIR_NAME, - dirs_exist_ok=True, - ignore=shutil.ignore_patterns("*.rst"), - ) return base_dir @@ -158,11 +152,7 @@ def filter_warnings_by_position( a random warning because 'test' is in the filename of 'graph/test_graph_checks.rst' """ prefix = f"{rst_data.filename}:{warning_info.lineno}: WARNING:" - return [ - warning.removeprefix(prefix) - for warning in warnings - if warning.startswith(prefix) - ] + return [warning.removeprefix(prefix) for warning in warnings if prefix in warning] def warning_matches( @@ -207,13 +197,18 @@ def test_rst_files( app.build() # Collect the warnings - raw_warnings = app.warning.getvalue().splitlines() - warnings = [strip_ansi_codes(w) for w in raw_warnings if "score_metamodel" in w] + raw_warnings: list[str] = app.warning.getvalue().splitlines() + warnings = [ + strip_ansi_codes(w) + for w in raw_warnings + if "score_metamodel" in w or "needs.needextend" in w + ] - # Enable this if you need to see errors for debugging purposes - # print( - # "\n".join(strip_ansi_codes(w) for w in raw_warnings if "score_metamodel" in w) - # ) + # ╓ ╖ + # ║ Enable this if you need to see errors for debugging ║ + # ║ purposes ║ + # ╙ ╜ + # print("\n".join(strip_ansi_codes(w) for w in warnings)) # Check if the expected warnings are present for warning_info in rst_data.warning_infos: