From fc699848b5c527d88058a2df18590725041dc8d5 Mon Sep 17 00:00:00 2001 From: Pierre-Etienne Date: Sun, 6 Apr 2025 15:26:56 +0200 Subject: [PATCH 01/12] implement step 3 of #1434 --- sphinx_needs/roles/need_ref.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sphinx_needs/roles/need_ref.py b/sphinx_needs/roles/need_ref.py index 118b1e317..04d865a73 100644 --- a/sphinx_needs/roles/need_ref.py +++ b/sphinx_needs/roles/need_ref.py @@ -85,8 +85,12 @@ def process_need_ref( need_id_full = node_need_ref["reftarget"] need_id_main, need_id_part = split_need_id(need_id_full) - if need_id_main in all_needs: - target_need = all_needs[need_id_main] + if need_id_main in all_needs or need_id_full in all_needs: + if need_id_main in all_needs: + target_need = all_needs[need_id_main] + else: # ie need_id_full in all_needs + target_need = all_needs[need_id_full] + need_id_part = None dict_need = transform_need_to_dict( target_need From 9e7899e094813fa4aee5c5e7c0df00a46098a66d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 6 Apr 2025 13:29:38 +0000 Subject: [PATCH 02/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- sphinx_needs/roles/need_ref.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx_needs/roles/need_ref.py b/sphinx_needs/roles/need_ref.py index 04d865a73..497f06860 100644 --- a/sphinx_needs/roles/need_ref.py +++ b/sphinx_needs/roles/need_ref.py @@ -88,7 +88,7 @@ def process_need_ref( if need_id_main in all_needs or need_id_full in all_needs: if need_id_main in all_needs: target_need = all_needs[need_id_main] - else: # ie need_id_full in all_needs + else: # ie need_id_full in all_needs target_need = all_needs[need_id_full] need_id_part = None From 50357795579e678500e13341bdf4cf8454941022 Mon Sep 17 00:00:00 2001 From: Pierre-Etienne Date: Sun, 6 Apr 2025 15:49:58 +0200 Subject: [PATCH 03/12] fix process_need_outgoing --- sphinx_needs/roles/need_outgoing.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sphinx_needs/roles/need_outgoing.py b/sphinx_needs/roles/need_outgoing.py index 056bb6e9b..8738a0bad 100644 --- a/sphinx_needs/roles/need_outgoing.py +++ b/sphinx_needs/roles/need_outgoing.py @@ -53,9 +53,14 @@ def process_need_outgoing( need_id_part and need_id_main in needs_all_needs and need_id_part in needs_all_needs[need_id_main]["parts"] - ): + ) or (need_id_full in needs_all_needs): try: - target_need = needs_all_needs[need_id_main] + if need_id_main in needs_all_needs: + target_need = needs_all_needs[need_id_main] + else: + target_need = needs_all_needs[need_id_full] + need_id_part = None + if need_id_part and need_id_part in target_need["parts"]: part_content = target_need["parts"][need_id_part]["content"] target_title = ( From 7b9447062c86cfac055b3a553083a4005aab7bad Mon Sep 17 00:00:00 2001 From: Pierre-Etienne Date: Sun, 6 Apr 2025 15:54:31 +0200 Subject: [PATCH 04/12] fix create_back_links --- sphinx_needs/directives/need.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index ac84d06d4..d9584643e 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -438,18 +438,25 @@ def create_back_links(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: for need_id_full in need_link_value: need_id_main, need_id_part = split_need_id(need_id_full) - if need_id_main in needs: - if key not in needs[need_id_main][option_back]: # type: ignore[literal-required] - needs[need_id_main][option_back].append(key) # type: ignore[literal-required] + if need_id_main in needs or need_id_full in needs: + if need_id_main in needs: + back_need = needs[need_id_main] + else: + back_need = needs[need_id_full] + need_id_part = None + + if key not in back_need[option_back]: # type: ignore[literal-required] + back_need[option_back].append(key) # type: ignore[literal-required] # Handling of links to need_parts inside a need - if need_id_part and need_id_part in needs[need_id_main]["parts"]: + if need_id_part and need_id_part in back_need["parts"]: if ( option_back - not in needs[need_id_main]["parts"][need_id_part] + not in back_need["parts"][need_id_part] ): - needs[need_id_main]["parts"][need_id_part][option_back] = [] # type: ignore[literal-required] - needs[need_id_main]["parts"][need_id_part][option_back].append( # type: ignore[literal-required] + back_need["parts"][need_id_part][option_back] = [] # type: ignore[literal-required] + + back_need["parts"][need_id_part][option_back].append( # type: ignore[literal-required] key ) From ff4264e8651df3d41baec614975c96acfdeef22d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 6 Apr 2025 13:55:32 +0000 Subject: [PATCH 05/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- sphinx_needs/directives/need.py | 5 +---- sphinx_needs/roles/need_outgoing.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index d9584643e..dd914cb8d 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -450,10 +450,7 @@ def create_back_links(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: # Handling of links to need_parts inside a need if need_id_part and need_id_part in back_need["parts"]: - if ( - option_back - not in back_need["parts"][need_id_part] - ): + if option_back not in back_need["parts"][need_id_part]: back_need["parts"][need_id_part][option_back] = [] # type: ignore[literal-required] back_need["parts"][need_id_part][option_back].append( # type: ignore[literal-required] diff --git a/sphinx_needs/roles/need_outgoing.py b/sphinx_needs/roles/need_outgoing.py index 8738a0bad..cfa0336ba 100644 --- a/sphinx_needs/roles/need_outgoing.py +++ b/sphinx_needs/roles/need_outgoing.py @@ -49,11 +49,15 @@ def process_need_outgoing( need_id_main, need_id_part = split_need_id(need_id_full) # If the need target exists, let's create the reference - if (need_id_main in needs_all_needs and not need_id_part) or ( - need_id_part - and need_id_main in needs_all_needs - and need_id_part in needs_all_needs[need_id_main]["parts"] - ) or (need_id_full in needs_all_needs): + if ( + (need_id_main in needs_all_needs and not need_id_part) + or ( + need_id_part + and need_id_main in needs_all_needs + and need_id_part in needs_all_needs[need_id_main]["parts"] + ) + or (need_id_full in needs_all_needs) + ): try: if need_id_main in needs_all_needs: target_need = needs_all_needs[need_id_main] From 0bd3ea7a880c8fa7ff09e4b8e5984935f62fa8d5 Mon Sep 17 00:00:00 2001 From: Pierre-Etienne Date: Wed, 21 May 2025 07:34:49 +0200 Subject: [PATCH 06/12] fix check_links warning when need_id_full is in needs --- sphinx_needs/directives/need.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index dd914cb8d..d0f10ec55 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -392,7 +392,10 @@ def check_links(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: for need_id_full in need_link_value: need_id_main, need_id_part = split_need_id(need_id_full) - if need_id_main not in needs or ( + if ( + need_id_main not in needs + and need_id_full not in needs + ) or ( need_id_main in needs and need_id_part and need_id_part not in needs[need_id_main]["parts"] From 230a4c5765c33fa04f1810645912954b487003a2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 05:35:16 +0000 Subject: [PATCH 07/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- sphinx_needs/directives/need.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index d0f10ec55..edf0a3e6d 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -392,10 +392,7 @@ def check_links(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: for need_id_full in need_link_value: need_id_main, need_id_part = split_need_id(need_id_full) - if ( - need_id_main not in needs - and need_id_full not in needs - ) or ( + if (need_id_main not in needs and need_id_full not in needs) or ( need_id_main in needs and need_id_part and need_id_part not in needs[need_id_main]["parts"] From 978c5bccbaad20ef123145125689e7e644efb5cc Mon Sep 17 00:00:00 2001 From: Marco Heinemann Date: Wed, 9 Jul 2025 11:02:12 +0200 Subject: [PATCH 08/12] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Drop=20Python=203.9?= =?UTF-8?q?=20(#1468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3.9 reaches its end of life on [2025-10](https://devguide.python.org/versions/). --- .github/workflows/ci.yaml | 10 +++++----- docs/contributing.rst | 2 +- pyproject.toml | 9 ++++----- sphinx_needs/api/configuration.py | 2 +- sphinx_needs/config.py | 4 ++-- sphinx_needs/debug.py | 3 ++- sphinx_needs/directives/need.py | 4 ++-- sphinx_needs/directives/needbar.py | 7 +++++-- sphinx_needs/directives/needextend.py | 4 ++-- sphinx_needs/directives/needflow/_graphviz.py | 3 ++- sphinx_needs/directives/needservice.py | 4 ++-- sphinx_needs/directives/needtable.py | 4 ++-- sphinx_needs/filter_common.py | 18 +++++++++--------- sphinx_needs/functions/functions.py | 16 +++++++++------- sphinx_needs/layout.py | 2 +- sphinx_needs/needs.py | 13 +++++++------ sphinx_needs/roles/need_ref.py | 2 +- sphinx_needs/services/open_needs.py | 6 +++--- sphinx_needs/utils.py | 7 ++++--- sphinx_needs/views.py | 4 ++-- 20 files changed, 66 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c3f5a729e..b96bbafd6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,7 +13,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' - uses: pre-commit/action@v3.0.1 tests-core: @@ -23,12 +23,12 @@ jobs: fail-fast: false # Set on "false" to get the results of ALL builds matrix: os: ["ubuntu-latest"] - python-version: ["3.9", "3.12", "3.13"] + python-version: ["3.10", "3.12", "3.13"] sphinx-version: ["7.4", "8.2"] include: # corner cases for Windows - os: "windows-latest" - python-version: "3.9" + python-version: "3.10" sphinx-version: "7.4" - os: "windows-latest" python-version: "3.12" @@ -39,7 +39,7 @@ jobs: exclude: # Sphinx 8.2 only supports py3.11+ - os: "ubuntu-latest" - python-version: "3.9" + python-version: "3.10" sphinx-version: "8.2" steps: @@ -81,7 +81,7 @@ jobs: matrix: include: - os: "ubuntu-latest" - python-version: "3.9" + python-version: "3.10" sphinx-version: "7.4" - os: "ubuntu-latest" python-version: "3.13" diff --git a/docs/contributing.rst b/docs/contributing.rst index 25420f7e2..b500bf35d 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -85,7 +85,7 @@ Or use tox (recommended): .. code-block:: bash - tox -e py39 + tox -e py310 Note some tests use `syrupy `__ to perform snapshot testing. These snapshots can be updated by running: diff --git a/pyproject.toml b/pyproject.toml index c9f4635b7..32eecab91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ classifiers = [ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', @@ -26,7 +25,7 @@ classifiers = [ 'Topic :: Utilities', 'Framework :: Sphinx :: Extension', ] -requires-python = ">=3.9,<4" +requires-python = ">=3.10,<4" dependencies = [ "sphinx>=7.4,<9", "requests-file~=2.1", # external links @@ -145,19 +144,19 @@ disable_error_code = ["no-redef"] legacy_tox_ini = """ [tox] -envlist = py39 +envlist = py10 [testenv] usedevelop = true -[testenv:py{39,310,311,312,313}] +[testenv:py{310,311,312,313}] extras = test test-parallel commands = pytest --ignore tests/benchmarks {posargs:tests} -[testenv:py{39,310,311,312,313}-benchmark] +[testenv:py{310,311,312,313}-benchmark] extras = test benchmark diff --git a/sphinx_needs/api/configuration.py b/sphinx_needs/api/configuration.py index d4a2bb86c..090d1109f 100644 --- a/sphinx_needs/api/configuration.py +++ b/sphinx_needs/api/configuration.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable from sphinx.application import Sphinx from sphinx.util.logging import SphinxLoggerAdapter diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index 7caa8420e..3c245609c 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -1,8 +1,8 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import MISSING, dataclass, field, fields -from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict from docutils.parsers.rst import directives from sphinx.application import Sphinx diff --git a/sphinx_needs/debug.py b/sphinx_needs/debug.py index 8c847a15d..975d0e02d 100644 --- a/sphinx_needs/debug.py +++ b/sphinx_needs/debug.py @@ -8,11 +8,12 @@ import inspect import json import os.path +from collections.abc import Callable from datetime import datetime from functools import wraps from pathlib import Path from timeit import default_timer as timer # Used for timing measurements -from typing import Any, Callable, TypeVar +from typing import Any, TypeVar from jinja2 import Environment, PackageLoader, select_autoescape from sphinx.application import Sphinx diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index edf0a3e6d..6f40c0d81 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -1,8 +1,8 @@ from __future__ import annotations import re -from collections.abc import Sequence -from typing import Any, Callable +from collections.abc import Callable, Sequence +from typing import Any from docutils import nodes from sphinx.addnodes import desc_name, desc_signature diff --git a/sphinx_needs/directives/needbar.py b/sphinx_needs/directives/needbar.py index 833d1a03f..bb63ea5a5 100644 --- a/sphinx_needs/directives/needbar.py +++ b/sphinx_needs/directives/needbar.py @@ -390,7 +390,9 @@ def process_needbar( if current_needbar["stacked"]: # handle stacked bar - y_offset = [i + j for i, j in zip(y_offset, local_data_number[x])] + y_offset = [ + i + j for i, j in zip(y_offset, local_data_number[x], strict=False) + ] if current_needbar["show_sum"]: try: @@ -427,7 +429,8 @@ def process_needbar( matplotlib.pyplot.setp(bar_labels, rotation=int(sum_rotation)) centers = [ - (i + j) / 2.0 for i, j in zip(index[0], index[len(local_data_number) - 1]) + (i + j) / 2.0 + for i, j in zip(index[0], index[len(local_data_number) - 1], strict=False) ] if not current_needbar["horizontal"]: # We want to support even older version of matplotlib, which do not support axes.set_xticks(labels) diff --git a/sphinx_needs/directives/needextend.py b/sphinx_needs/directives/needextend.py index b4e88be27..c9fb100eb 100644 --- a/sphinx_needs/directives/needextend.py +++ b/sphinx_needs/directives/needextend.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any, Callable +from collections.abc import Callable, Sequence +from typing import Any from docutils import nodes from docutils.parsers.rst import directives diff --git a/sphinx_needs/directives/needflow/_graphviz.py b/sphinx_needs/directives/needflow/_graphviz.py index 83b658e2b..ed8d9a256 100644 --- a/sphinx_needs/directives/needflow/_graphviz.py +++ b/sphinx_needs/directives/needflow/_graphviz.py @@ -2,8 +2,9 @@ import html import textwrap +from collections.abc import Callable from functools import cache -from typing import Callable, Literal, TypedDict +from typing import Literal, TypedDict from urllib.parse import urlparse from docutils import nodes diff --git a/sphinx_needs/directives/needservice.py b/sphinx_needs/directives/needservice.py index 3126fc46e..42fc9bda8 100644 --- a/sphinx_needs/directives/needservice.py +++ b/sphinx_needs/directives/needservice.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import Any, Callable +from collections.abc import Callable, Sequence +from typing import Any from docutils import nodes from docutils.parsers.rst import directives diff --git a/sphinx_needs/directives/needtable.py b/sphinx_needs/directives/needtable.py index d006499fd..6583fdf14 100644 --- a/sphinx_needs/directives/needtable.py +++ b/sphinx_needs/directives/needtable.py @@ -1,8 +1,8 @@ from __future__ import annotations import re -from collections.abc import Sequence -from typing import Any, Callable +from collections.abc import Callable, Sequence +from typing import Any from docutils import nodes from docutils.parsers.rst import directives diff --git a/sphinx_needs/filter_common.py b/sphinx_needs/filter_common.py index a01179611..ccc28703e 100644 --- a/sphinx_needs/filter_common.py +++ b/sphinx_needs/filter_common.py @@ -8,11 +8,11 @@ import ast import json import re -from collections.abc import Iterable +from collections.abc import Callable, Iterable from pathlib import Path from timeit import default_timer as timer from types import CodeType -from typing import Any, Callable, TypedDict, overload +from typing import Any, TypedDict, overload from docutils import nodes from docutils.parsers.rst import directives @@ -318,8 +318,8 @@ def _analyze_and_apply_expr( :returns: the needs (potentially filtered), and a boolean denoting if it still requires python eval filtering """ - if isinstance(expr, (ast.Str, ast.Constant)): - if isinstance(expr.s, (str, bool)): + if isinstance(expr, ast.Str | ast.Constant): + if isinstance(expr.s, str | bool): # "value" / True / False return needs if expr.s else needs.filter_ids([]), False @@ -335,13 +335,13 @@ def _analyze_and_apply_expr( if ( isinstance(expr.left, ast.Name) and len(expr.comparators) == 1 - and isinstance(expr.comparators[0], (ast.Str, ast.Constant)) + and isinstance(expr.comparators[0], ast.Str | ast.Constant) ): # x == "value" field = expr.left.id value = expr.comparators[0].s elif ( - isinstance(expr.left, (ast.Str, ast.Constant)) + isinstance(expr.left, ast.Str | ast.Constant) and len(expr.comparators) == 1 and isinstance(expr.comparators[0], ast.Name) ): @@ -369,9 +369,9 @@ def _analyze_and_apply_expr( if ( isinstance(expr.left, ast.Name) and len(expr.comparators) == 1 - and isinstance(expr.comparators[0], (ast.List, ast.Tuple, ast.Set)) + and isinstance(expr.comparators[0], ast.List | ast.Tuple | ast.Set) and all( - isinstance(elt, (ast.Str, ast.Constant)) + isinstance(elt, ast.Str | ast.Constant) for elt in expr.comparators[0].elts ) ): @@ -386,7 +386,7 @@ def _analyze_and_apply_expr( # type in ["a", "b", ...] return needs.filter_types(values), False elif ( - isinstance(expr.left, (ast.Str, ast.Constant)) + isinstance(expr.left, ast.Str | ast.Constant) and len(expr.comparators) == 1 and isinstance(expr.comparators[0], ast.Name) and expr.comparators[0].id == "tags" diff --git a/sphinx_needs/functions/functions.py b/sphinx_needs/functions/functions.py index 525421f7b..f5af5b0a8 100644 --- a/sphinx_needs/functions/functions.py +++ b/sphinx_needs/functions/functions.py @@ -112,7 +112,9 @@ def execute_func( ) return "??" - if func_return is not None and not isinstance(func_return, (str, int, float, list)): + if func_return is not None and not isinstance( + func_return, str | int | float | list + ): log_warning( logger, f"Return value of function {func_name!r} is of type {type(func_return)}. Allowed are str, int, float, list", @@ -122,7 +124,7 @@ def execute_func( return "??" if isinstance(func_return, list): for i, element in enumerate(func_return): - if not isinstance(element, (str, int, float)): + if not isinstance(element, str | int | float): log_warning( logger, f"Return value item {i} of function {func_name!r} is of type {type(element)}. Allowed are str, int, float", @@ -204,7 +206,7 @@ def find_and_replace_node_content( return node else: for child in node.children: - if isinstance(child, (nodes.literal_block, nodes.literal, Need)): + if isinstance(child, nodes.literal_block | nodes.literal | Need): # Do not parse literal blocks or nested needs new_children.append(child) continue @@ -253,7 +255,7 @@ def resolve_dynamic_values(needs: NeedsMutable, app: Sphinx) -> None: for need_option in need: if need_option not in allowed_fields: continue - if not isinstance(need[need_option], (list, set)): + if not isinstance(need[need_option], list | set): func_call: str | None = "init" while func_call: try: @@ -314,7 +316,7 @@ def resolve_dynamic_values(needs: NeedsMutable, app: Sphinx) -> None: ) if func_call is None: new_values.append(element) - elif isinstance(func_return, (list, set)): + elif isinstance(func_return, list | set): new_values += func_return else: new_values += [func_return] @@ -370,7 +372,7 @@ def resolve_variants_options( for var_option in variants_options: if ( var_option in need - and isinstance(need[var_option], (str, list, tuple, set)) + and isinstance(need[var_option], str | list | tuple | set) and ( result := match_variants( need[var_option], @@ -476,7 +478,7 @@ def _analyze_func_string( for arg in func_call.args: if isinstance(arg, ast.Num): func_args.append(arg.n) - elif isinstance(arg, (ast.Str, ast.BoolOp)): + elif isinstance(arg, ast.Str | ast.BoolOp): func_args.append(arg.s) # type: ignore elif isinstance(arg, ast.List): arg_list: list[Any] = [] diff --git a/sphinx_needs/layout.py b/sphinx_needs/layout.py index 1009ccad9..63e570aa9 100644 --- a/sphinx_needs/layout.py +++ b/sphinx_needs/layout.py @@ -9,11 +9,11 @@ import os import re import uuid +from collections.abc import Callable from contextlib import suppress from functools import lru_cache from optparse import Values from pathlib import Path -from typing import Callable from urllib.parse import urlparse import requests diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index 863243fbb..7eab592c5 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -1,9 +1,10 @@ from __future__ import annotations import contextlib +from collections.abc import Callable from pathlib import Path from timeit import default_timer as timer # Used for timing measurements -from typing import Any, Callable, Literal +from typing import Any, Literal from docutils import nodes from docutils.parsers.rst import directives @@ -782,9 +783,9 @@ def _gather_field_defaults( k: v for k, v in value.items() if k in {"predicates", "default"} } if "predicates" in single_default and ( - not isinstance(single_default["predicates"], (list, tuple)) + not isinstance(single_default["predicates"], list | tuple) or not all( - isinstance(x, (list, tuple)) + isinstance(x, list | tuple) and len(x) == 2 and isinstance(x[0], str) for x in single_default["predicates"] @@ -798,9 +799,9 @@ def _gather_field_defaults( ) continue elif ( - isinstance(value, (list, tuple)) + isinstance(value, list | tuple) and len(value) > 0 - and all(isinstance(x, (list, tuple)) for x in value) + and all(isinstance(x, list | tuple) for x in value) ): old_format = True single_default = {"predicates": []} @@ -830,7 +831,7 @@ def _gather_field_defaults( "config", None, ) - elif isinstance(value, (list, tuple)): + elif isinstance(value, list | tuple): old_format = True if len(value) == 2: # single (value, predicate) pair diff --git a/sphinx_needs/roles/need_ref.py b/sphinx_needs/roles/need_ref.py index 497f06860..9ee07f58b 100644 --- a/sphinx_needs/roles/need_ref.py +++ b/sphinx_needs/roles/need_ref.py @@ -50,7 +50,7 @@ def value_to_string(value: Any) -> str: return value elif isinstance(value, dict): return ";".join([str(i) for i in value.items()]) - elif isinstance(value, (Iterable, list, tuple)): + elif isinstance(value, Iterable | list | tuple): return ";".join([str(i) for i in value]) return str(value) diff --git a/sphinx_needs/services/open_needs.py b/sphinx_needs/services/open_needs.py index 7c312413e..23cc0b77c 100644 --- a/sphinx_needs/services/open_needs.py +++ b/sphinx_needs/services/open_needs.py @@ -142,7 +142,7 @@ def _extract_data( for item in data: extra_data = {} for name, selector in self.extra_data.items(): - if not isinstance(selector, (tuple, list, str)): + if not isinstance(selector, tuple | list | str): raise InvalidConfigException( f"Given selector for {name} of extra_data must be a list or tuple. " f'Got {type(selector)} with value "{selector}"' @@ -172,7 +172,7 @@ def _extract_data( need_values = {} for name, selector in self.mappings.items(): - if not isinstance(selector, (tuple, list, str)): + if not isinstance(selector, tuple | list | str): raise InvalidConfigException( f"Given selector for {name} of mapping must be a list or tuple. " f'Got {type(selector)} with value "{selector}"' @@ -186,7 +186,7 @@ def _extract_data( need_values[name] = selector else: value = dict_get(item, selector) - if isinstance(value, (tuple, list)): + if isinstance(value, tuple | list): if name == "links": # Add a prefix to the referenced link if it is an ID of a need object in # the data retrieved from the Open Needs Server or don't add prefix diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py index ce577744e..2934118f4 100644 --- a/sphinx_needs/utils.py +++ b/sphinx_needs/utils.py @@ -5,9 +5,10 @@ import operator import os import re +from collections.abc import Callable from dataclasses import dataclass from functools import lru_cache, reduce, wraps -from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Protocol, TypeVar from urllib.parse import urlparse from docutils import nodes @@ -98,7 +99,7 @@ def row_col_maker( if need_key in need_info and need_info[need_key] is not None: # type: ignore[literal-required] value = need_info[need_key] # type: ignore[literal-required] - if isinstance(value, (list, set)): + if isinstance(value, list | set): data = value elif isinstance(value, str) and need_key in needs_string_links_option: data = re.split(r",|;", value) @@ -530,7 +531,7 @@ def match_variants( for i in options_list if i not in (None, ";", "", " ") ] - elif isinstance(options, (list, set, tuple)): + elif isinstance(options, list | set | tuple): options_list = [str(opt) for opt in options] else: raise TypeError( diff --git a/sphinx_needs/views.py b/sphinx_needs/views.py index 43ab0d08b..31059a6c7 100644 --- a/sphinx_needs/views.py +++ b/sphinx_needs/views.py @@ -2,13 +2,13 @@ from collections.abc import Iterable, Iterator, Mapping from itertools import chain -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from sphinx_needs.data import NeedsInfoType -_IdSet = list[tuple[str, Optional[str]]] +_IdSet = list[tuple[str, str | None]] """Set of (need, part) ids.""" From bda138cf616341f216b51564913dc9e1a4960c5a Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Fri, 11 Jul 2025 09:23:51 +0200 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=91=8C=20Improve=20need=20part=20pr?= =?UTF-8?q?ocessing=20(#1469)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `need_part`/`np` role is not a reference and should not use an `XRefRole` subclass (see #1437). In addition, the processing now correctly handles and warns on need parts that are not part of a need and `need` roles that reference an unknown part. --- pyproject.toml | 2 +- sphinx_needs/logging.py | 2 + sphinx_needs/needs.py | 17 +--- sphinx_needs/roles/need_part.py | 104 ++++++++++++++++-------- sphinx_needs/roles/need_ref.py | 8 -- tests/doc_test/doc_need_parts/index.rst | 3 + tests/test_need_parts.py | 5 +- 7 files changed, 81 insertions(+), 60 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 32eecab91..0dbd259d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,7 @@ disable_error_code = ["no-redef"] legacy_tox_ini = """ [tox] -envlist = py10 +envlist = py310 [testenv] usedevelop = true diff --git a/sphinx_needs/logging.py b/sphinx_needs/logging.py index 17e1ee428..e5a88f2e2 100644 --- a/sphinx_needs/logging.py +++ b/sphinx_needs/logging.py @@ -42,6 +42,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "load_external_need", "load_service_need", "mpl", + "part", "title", "uml", "unknown_external_keys", @@ -81,6 +82,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "load_external_need": "Failed to load an external need", "load_service_need": "Failed to load a service need", "mpl": "Matplotlib required but not installed", + "part": "Error processing need part", "title": "Error creating need title", "uml": "Error in processing of UML diagram", "unknown_external_keys": "Unknown keys found in external need data", diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index 7eab592c5..67267a7f5 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -114,7 +114,7 @@ from sphinx_needs.roles.need_func import NeedFunc, NeedFuncRole, process_need_func from sphinx_needs.roles.need_incoming import NeedIncoming, process_need_incoming from sphinx_needs.roles.need_outgoing import NeedOutgoing, process_need_outgoing -from sphinx_needs.roles.need_part import NeedPart, process_need_part +from sphinx_needs.roles.need_part import NeedPart, NeedPartRole, process_need_part from sphinx_needs.roles.need_ref import NeedRef, process_need_ref from sphinx_needs.services.github import GithubService from sphinx_needs.services.open_needs import OpenNeedsService @@ -242,19 +242,8 @@ def setup(app: Sphinx) -> dict[str, Any]: ), ) - app.add_role( - "need_part", - NeedsXRefRole( - nodeclass=NeedPart, innernodeclass=nodes.inline, warn_dangling=True - ), - ) - # Shortcut for need_part - app.add_role( - "np", - NeedsXRefRole( - nodeclass=NeedPart, innernodeclass=nodes.inline, warn_dangling=True - ), - ) + app.add_role("need_part", NeedPartRole()) + app.add_role("np", NeedPartRole()) # Shortcut for need_part app.add_role( "need_count", diff --git a/sphinx_needs/roles/need_part.py b/sphinx_needs/roles/need_part.py index fc1d789cc..f14dd82ea 100644 --- a/sphinx_needs/roles/need_part.py +++ b/sphinx_needs/roles/need_part.py @@ -3,8 +3,6 @@ --------------- Provides the ability to mark specific parts of a need with an own id. -Most voodoo is done in need.py - """ from __future__ import annotations @@ -12,22 +10,65 @@ import hashlib import re from collections.abc import Iterable -from typing import cast from docutils import nodes from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment +from sphinx.util.docutils import SphinxRole from sphinx.util.nodes import make_refnode from sphinx_needs.data import NeedsInfoType, NeedsPartType from sphinx_needs.logging import get_logger, log_warning from sphinx_needs.nodes import Need -log = get_logger(__name__) +LOGGER = get_logger(__name__) class NeedPart(nodes.Inline, nodes.Element): - pass + @property + def title(self) -> str: + """Return the title of the part.""" + return self.attributes["title"] # type: ignore[no-any-return] + + @property + def part_id(self) -> str: + """Return the ID of the part.""" + return self.attributes["part_id"] # type: ignore[no-any-return] + + @property + def need_id(self) -> str | None: + """Return the ID of the need this part belongs to.""" + return self.attributes["need_id"] # type: ignore[no-any-return] + + @need_id.setter + def need_id(self, value: str) -> None: + """Set the ID of the need this part belongs to.""" + self.attributes["need_id"] = value + + +_PART_PATTERN = re.compile(r"\(([\w-]+)\)(.*)", re.DOTALL) + + +class NeedPartRole(SphinxRole): + """ + Role for need parts, which are sub-needs of a need. + It is used to mark parts of a need with an own id. + """ + + def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: + # note self.text is the content of the role, with backslash escapes removed + # TODO perhaps in a future change we should allow escaping parentheses in the part id? + # and also strip (unescaped) space before/after the title + result = _PART_PATTERN.match(self.text) + if result: + id_ = result.group(1) + title = result.group(2) + else: + id_ = hashlib.sha1(self.text.encode("UTF-8")).hexdigest().upper()[:3] + title = self.text + part = NeedPart(title=title, part_id=id_, need_id=None) + self.set_source_info(part) + return [part], [] def process_need_part( @@ -36,10 +77,16 @@ def process_need_part( fromdocname: str, found_nodes: list[nodes.Element], ) -> None: - pass - - -part_pattern = re.compile(r"\(([\w-]+)\)(.*)", re.DOTALL) + # note this is called after needs have been processed and parts collected. + for node in found_nodes: + assert isinstance(node, NeedPart), "Expected NeedPart node" + if node.need_id is None: + log_warning( + LOGGER, + "Need part not associated with a need.", + "part", + node, + ) def create_need_from_part(need: NeedsInfoType, part: NeedsPartType) -> NeedsInfoType: @@ -67,58 +114,43 @@ def update_need_with_parts( ) -> None: app = env.app for part_node in part_nodes: - content = cast(str, part_node.children[0].children[0]) # ->inline->Text - result = part_pattern.match(content) - if result: - inline_id = result.group(1) - part_content = result.group(2) - else: - part_content = content - inline_id = ( - hashlib.sha1(part_content.encode("UTF-8")).hexdigest().upper()[:3] - ) + part_id = part_node.part_id if "parts" not in need: need["parts"] = {} - if inline_id in need["parts"]: + if part_id in need["parts"]: log_warning( - log, + LOGGER, "part_need id {} in need {} is already taken. need_part may get overridden.".format( - inline_id, need["id"] + part_id, need["id"] ), "duplicate_part_id", part_node, ) - need["parts"][inline_id] = { - "id": inline_id, - "content": part_content, + need["parts"][part_id] = { + "id": part_id, + "content": part_node.title, "links": [], "links_back": [], } - part_id_ref = "{}.{}".format(need["id"], inline_id) - + part_node.need_id = need["id"] + part_id_ref = "{}.{}".format(need["id"], part_id) part_node["reftarget"] = part_id_ref - part_text_node = nodes.Text(part_content) - - part_node.children = [] node_need_part_line = nodes.inline(ids=[part_id_ref], classes=["need-part"]) - node_need_part_line.append(part_text_node) + node_need_part_line.append(nodes.Text(part_node.title)) if docname := need["docname"]: - part_id_show = inline_id - part_link_text = f" {part_id_show}" - part_link_node = nodes.Text(part_link_text) - part_ref_node = make_refnode( - app.builder, docname, docname, part_id_ref, part_link_node + app.builder, docname, docname, part_id_ref, nodes.Text(f" {part_id}") ) part_ref_node["classes"] += ["needs-id"] node_need_part_line.append(part_ref_node) + part_node.children = [] part_node.append(node_need_part_line) diff --git a/sphinx_needs/roles/need_ref.py b/sphinx_needs/roles/need_ref.py index 9ee07f58b..a7592ef8d 100644 --- a/sphinx_needs/roles/need_ref.py +++ b/sphinx_needs/roles/need_ref.py @@ -166,12 +166,4 @@ def process_need_ref( ) new_node_ref["classes"].append(target_need["external_css"]) - else: - log_warning( - log, - f"linked need {node_need_ref['reftarget']} not found", - "link_ref", - location=node_need_ref, - ) - node_need_ref.replace_self(new_node_ref) diff --git a/tests/doc_test/doc_need_parts/index.rst b/tests/doc_test/doc_need_parts/index.rst index 1a4073654..9e4fcf865 100644 --- a/tests/doc_test/doc_need_parts/index.rst +++ b/tests/doc_test/doc_need_parts/index.rst @@ -23,6 +23,7 @@ NEED PARTS Part in nested need: :need_part:`(nested_id)something` +:np:`hallo` :need:`SP_TOO_001.1` @@ -31,3 +32,5 @@ NEED PARTS :need:`SP_TOO_001.awesome_id` :need:`My custom link name ` + +:need:`SP_TOO_001.unknown_part` diff --git a/tests/test_need_parts.py b/tests/test_need_parts.py index ba0b94795..957aaff4b 100644 --- a/tests/test_need_parts.py +++ b/tests/test_need_parts.py @@ -19,7 +19,10 @@ def test_doc_need_parts(test_app, snapshot): app._warning.getvalue().replace(str(app.srcdir) + os.sep, "srcdir/") ).splitlines() # print(warnings) - assert warnings == [] + assert warnings == [ + "srcdir/index.rst:26: WARNING: Need part not associated with a need. [needs.part]", + "srcdir/index.rst:36: WARNING: linked need part SP_TOO_001.unknown_part not found [needs.link_ref]", + ] html = Path(app.outdir, "index.html").read_text() assert ( From f87ca13a70502ec6a743de18a44fa225a82b197b Mon Sep 17 00:00:00 2001 From: Marco Heinemann Date: Fri, 11 Jul 2025 09:35:24 +0200 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=93=9A=20Fix=20escape=20sequences?= =?UTF-8?q?=20(#1470)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes the docs build warnings emitted by RST highlighting: ``` :1: SyntaxWarning: invalid escape sequence '\.' :1: SyntaxWarning: invalid escape sequence '\.' :1: SyntaxWarning: invalid escape sequence '\w' :1: SyntaxWarning: invalid escape sequence '\w' ``` --- docs/filter.rst | 2 +- docs/roles.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/filter.rst b/docs/filter.rst index 71d961aa9..ba673b6bd 100644 --- a/docs/filter.rst +++ b/docs/filter.rst @@ -225,7 +225,7 @@ The second parameter should be one of the above variables(status, id, content, . .. req:: Set admin e-mail to admin@mycompany.com .. needlist:: - :filter: search("([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", title) + :filter: search(r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", title) .. _filter_string_performance: diff --git a/docs/roles.rst b/docs/roles.rst index 428da03df..fc2edf85f 100644 --- a/docs/roles.rst +++ b/docs/roles.rst @@ -153,7 +153,7 @@ See :ref:`filter_string` for more information. | Specification needs: :need_count:`type=='spec'` | Open specification needs: :need_count:`type=='spec' and status=='open'` | Needs with tag *test*: :need_count:`'test' in tags` - | Needs with title longer 10 chars: :need_count:`search("[\\w\\s]{10,}", title)` + | Needs with title longer 10 chars: :need_count:`search(r"[\w\s]{10,}", title)` | All need_parts: :need_count:`is_part` | All needs containing need_parts: :need_count:`is_need and len(parts)>0` From 16a81054cda52c5c704e1e122b8bf4dc93d42ae4 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 23 Jul 2025 10:21:00 +0200 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=93=9A=20Format=20configuration.rst?= =?UTF-8?q?=20(#1473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/configuration.rst | 1048 ++++++++++++++++++++-------------------- 1 file changed, 517 insertions(+), 531 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 152b1869b..e2fbf102a 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -39,10 +39,11 @@ Find below a list of all warnings, which can be suppressed: .. need-warnings:: -.. _inc_build: +.. _`inc_build`: Incremental build support ------------------------- + Sphinx does not use its incremental build feature, if you assign functions directly to Sphinx options. To avoid this, please use the :ref:`Sphinx-Needs API ` to register functions directly. @@ -86,7 +87,7 @@ Options All configuration options starts with the prefix ``needs_`` for **Sphinx-Needs**. -.. _needs_from_toml: +.. _`needs_from_toml`: needs_from_toml ~~~~~~~~~~~~~~~ @@ -124,27 +125,29 @@ For example to read from a ``[tool.needs]`` table: needs_include_needs ~~~~~~~~~~~~~~~~~~~ + Set this option to False, if no needs should be documented inside the generated documentation. Default: **True** .. code-block:: python - needs_include_needs = False + needs_include_needs = False -.. _needs_id_length: +.. _`needs_id_length`: needs_id_length ~~~~~~~~~~~~~~~ + This option defines the length of an automated generated ID (the length of the prefix does not count). Default: **5** .. code-block:: python - needs_id_length = 3 + needs_id_length = 3 -.. _needs_types: +.. _`needs_types`: needs_types ~~~~~~~~~~~ @@ -155,13 +158,13 @@ By default it is set to: .. code-block:: python - needs_types = [dict(directive="req", title="Requirement", prefix="R_", color="#BFD8D2", style="node"), - dict(directive="spec", title="Specification", prefix="S_", color="#FEDCD2", style="node"), - dict(directive="impl", title="Implementation", prefix="I_", color="#DF744A", style="node"), - dict(directive="test", title="Test Case", prefix="T_", color="#DCB239", style="node"), - # Kept for backwards compatibility - dict(directive="need", title="Need", prefix="N_", color="#9856a5", style="node") - ] + needs_types = [dict(directive="req", title="Requirement", prefix="R_", color="#BFD8D2", style="node"), + dict(directive="spec", title="Specification", prefix="S_", color="#FEDCD2", style="node"), + dict(directive="impl", title="Implementation", prefix="I_", color="#DF744A", style="node"), + dict(directive="test", title="Test Case", prefix="T_", color="#DCB239", style="node"), + # Kept for backwards compatibility + dict(directive="need", title="Need", prefix="N_", color="#9856a5", style="node") + ] ``needs_types`` must be a list of dictionaries where each dictionary must contain the following items: @@ -187,8 +190,7 @@ By default it is set to: Please take a look into the `PlantUML Manual `_ for more details. - -.. _needs_extra_options: +.. _`needs_extra_options`: needs_extra_options ~~~~~~~~~~~~~~~~~~~ @@ -204,7 +206,6 @@ You can set ``needs_extra_options`` as a list inside **conf.py** as follows: needs_extra_options = ['introduced', 'updated', 'impacts'] - And use it like: .. code-block:: rst @@ -216,9 +217,10 @@ And use it like: :tags: important;complex; :impacts: really everything -.. note:: To filter on these options in `needlist`, `needtable`, etc. you - must use the :ref:`filter` option. +.. note:: + To filter on these options in `needlist`, `needtable`, etc. you + must use the :ref:`filter` option. .. dropdown:: Show example @@ -258,70 +260,72 @@ And use it like: .. versionadded:: 4.1.0 - Values in the list can also be dictionaries, with keys: + Values in the list can also be dictionaries, with keys: - * ``name``: The name of the option (required). - * ``description``: A description of the option (optional). - This will be output in the schema of the :ref:`needs.json `, - and can be used by other tools. + * ``name``: The name of the option (required). + * ``description``: A description of the option (optional). + This will be output in the schema of the :ref:`needs.json `, + and can be used by other tools. - For example: + For example: - .. code-block:: python + .. code-block:: python - needs_extra_options = [ - "my_extra_option", - {"name": "my_other_option", "description": "This is a description of the option"} - ] + needs_extra_options = [ + "my_extra_option", + {"name": "my_other_option", "description": "This is a description of the option"} + ] -.. _needs_global_options: -.. _global_option_filters: +.. _`needs_global_options`: +.. _`global_option_filters`: needs_global_options ~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.3.0 + .. versionchanged:: 5.1.0 - The format of the global options was change to be more explicit. - - Unknown keys are also no longer accepted, - these should also be set in the :ref:`needs_extra_options` list. - - .. dropdown:: Comparison to old format - - .. code-block:: python - :caption: Old format - - needs_global_options = { - "field1": "a", - "field2": ("a", 'status == "done"'), - "field3": ("a", 'status == "done"', "b"), - "field4": [ - ("a", 'status == "done"'), - ("b", 'status == "ongoing"'), - ("c", 'status == "other"', "d"), - ], - } - - .. code-block:: python - :caption: New format - - needs_global_options = { - "field1": {"default": "a"}, - "field2": {"predicates": [('status == "done"', "a")]}, - "field3": { - "predicates": [('status == "done"', "a")], - "default": "b", - }, - "field4": { - "predicates": [ - ('status == "done"', "a"), - ('status == "ongoing"', "b"), - ('status == "other"', "c"), - ], - "default": "d", - }, - } + The format of the global options was change to be more explicit. + + Unknown keys are also no longer accepted, + these should also be set in the :ref:`needs_extra_options` list. + + .. dropdown:: Comparison to old format + + .. code-block:: python + :caption: Old format + + needs_global_options = { + "field1": "a", + "field2": ("a", 'status == "done"'), + "field3": ("a", 'status == "done"', "b"), + "field4": [ + ("a", 'status == "done"'), + ("b", 'status == "ongoing"'), + ("c", 'status == "other"', "d"), + ], + } + + .. code-block:: python + :caption: New format + + needs_global_options = { + "field1": {"default": "a"}, + "field2": {"predicates": [('status == "done"', "a")]}, + "field3": { + "predicates": [('status == "done"', "a")], + "default": "b", + }, + "field4": { + "predicates": [ + ('status == "done"', "a"), + ('status == "ongoing"', "b"), + ('status == "other"', "c"), + ], + "default": "d", + }, + } This configuration allows for global defaults to be set for all needs, for any of the following fields: @@ -366,15 +370,15 @@ If no predicates match, the ``default`` value is used (if present). } .. tip:: - - You can combine global options with :ref:`dynamic_functions` to automate data handling. - .. code-block:: python + You can combine global options with :ref:`dynamic_functions` to automate data handling. + + .. code-block:: python - needs_extra_options = ["option1"] - needs_global_options = { - "option1": {"default": '[[copy("id")]]'} - } + needs_extra_options = ["option1"] + needs_global_options = { + "option1": {"default": '[[copy("id")]]'} + } .. warning:: @@ -387,16 +391,16 @@ If no predicates match, the ``default`` value is used (if present). Default replacements are done, for each field, in the order they are defined in the configuration, so a filter string should not depend on the value of a field below it in the configuration. -.. _needs_report_dead_links: +.. _`needs_report_dead_links`: needs_report_dead_links ~~~~~~~~~~~~~~~~~~~~~~~ .. deprecated:: 2.1.0 - Instead add ``needs.link_outgoing`` to the `suppress_warnings `__ list:: + Instead add ``needs.link_outgoing`` to the `suppress_warnings `__ list:: - suppress_warnings = ["needs.link_outgoing"] + suppress_warnings = ["needs.link_outgoing"] Deactivate/activate log messages of disallowed outgoing dead links. If set to ``False``, then deactivate. @@ -406,9 +410,9 @@ Configuration example: .. code-block:: python - needs_report_dead_links = False + needs_report_dead_links = False -.. _needs_extra_links: +.. _`needs_extra_links`: needs_extra_links ~~~~~~~~~~~~~~~~~ @@ -430,7 +434,6 @@ Each configured link should define: * **style_part** (optional): Same as **style**, but get used if link is connected to a :ref:`need_part`. See :ref:`links_style`. - Configuration example: .. code-block:: python @@ -454,19 +457,17 @@ Configuration example: } ] - The above example configuration allows the following usage: .. need-example:: - .. req:: My requirement - :id: EXTRA_REQ_001 - - .. test:: Test of requirements - :id: EXTRA_TEST_001 - :checks: EXTRA_REQ_001, DEAD_LINK_NOT_ALLOWED - :triggers: DEAD_LINK + .. req:: My requirement + :id: EXTRA_REQ_001 + .. test:: Test of requirements + :id: EXTRA_TEST_001 + :checks: EXTRA_REQ_001, DEAD_LINK_NOT_ALLOWED + :triggers: DEAD_LINK .. attention:: The used option name can not be reused in the configuration of :ref:`needs_global_options`. @@ -474,7 +475,7 @@ Link types with option-name **links** and **parent_needs** are added by default. You are free to overwrite the default config by defining your own type with option name **links** or **parent_needs**. This type will be used as default configuration for all links. -.. _allow_dead_links: +.. _`allow_dead_links`: allow_dead_links ++++++++++++++++ @@ -486,6 +487,7 @@ Instead the same text gets printed as log message on level ``INFO``. Filtering ^^^^^^^^^ + Need objects have the two attributes ``has_dead_links`` and ``has_forbidden_dead_links``. ``has_dead_links`` gets set to ``True``, if any dead link was found in the need. And ``has_forbidden_dead_links`` is set to ``True`` only, if dead links were not allowed @@ -500,8 +502,7 @@ with ``allow_dead_links`` not set or set to ``False``. By default not allowed dead links will be shown in red , allowed ones in gray (see above example). - -.. _links_style: +.. _`links_style`: style / style_part ++++++++++++++++++ @@ -519,7 +520,7 @@ Valid configuration examples are: An empty string uses the default plantuml settings. -.. _needflow_style_start: +.. _`needflow_style_start`: style_start / style_end +++++++++++++++++++++++ @@ -572,8 +573,7 @@ Use ``style_start`` and ``style_end`` like this: and orientation (`left`, `rigth`, `up` and `down`). We suggest to set the orientation in `style_end` like in the example above, as this is more often supported. - -.. _needs_filter_data: +.. _`needs_filter_data`: needs_filter_data ~~~~~~~~~~~~~~~~~ @@ -592,7 +592,6 @@ Configuration example: "sphinx_tag": custom_defined_func(), } - The defined ``needs_filter_data`` must be a dictionary. Its values can be a string variable or a custom defined function. The function get executed during config loading and must return a string. @@ -606,7 +605,6 @@ The defined extra filter data can be used like this: .. needextend:: type == "req" and sphinx_tag in tags :+tags: my_external_tag - or if project has :ref:`needs_extra_options` defined like: .. code-block:: python @@ -625,8 +623,7 @@ The defined extra filter data can also be used like: :layout: clean :style: green_border - -.. _needs_allow_unsafe_filters: +.. _`needs_allow_unsafe_filters`: needs_allow_unsafe_filters ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -636,9 +633,7 @@ Allow unsafe filter for :ref:`filter_func`. Default is ``False``. If set to True, the filtered results will keep all fields as they are returned by the dynamic functions. Fields can be added or existing fields can even be manipulated. -.. note:: - - Keep in mind this only affects the filter results, original needs as displayed somewhere else are not modified. +.. note:: Keep in mind this only affects the filter results, original needs as displayed somewhere else are not modified. If set to False, the filter results contains the original need fields and any manipulations of need fields are lost. @@ -646,8 +641,7 @@ If set to False, the filter results contains the original need fields and any ma needs_allow_unsafe_filters = True - -.. _needs_filter_max_time: +.. _`needs_filter_max_time`: needs_filter_max_time ~~~~~~~~~~~~~~~~~~~~~ @@ -656,7 +650,7 @@ needs_filter_max_time If set, warn if any :ref:`filter processing ` call takes longer than the given time in seconds. -.. _needs_uml_process_max_time: +.. _`needs_uml_process_max_time`: needs_uml_process_max_time ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -665,7 +659,7 @@ needs_uml_process_max_time If set, warn if any :ref:`needuml` or :ref:`needarch` jinja content rendering takes longer than the given time in seconds. -.. _needs_flow_engine: +.. _`needs_flow_engine`: needs_flow_engine ~~~~~~~~~~~~~~~~~ @@ -677,7 +671,7 @@ 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`: needs_flow_show_links ~~~~~~~~~~~~~~~~~~~~~ @@ -690,12 +684,11 @@ Used to de/activate the output of link type names beside the connection in the : needs_flow_show_links = True - Default value: ``False`` Can be configured also for each :ref:`needflow` directive via :ref:`needflow_show_link_names`. -.. _needs_flow_link_types: +.. _`needs_flow_link_types`: needs_flow_link_types ~~~~~~~~~~~~~~~~~~~~~ @@ -713,7 +706,7 @@ See also :ref:`needflow_link_types` for more details. Default value: ``['links']`` -.. _needs_flow_configs: +.. _`needs_flow_configs`: needs_flow_configs ~~~~~~~~~~~~~~~~~~ @@ -743,17 +736,17 @@ This configurations can then be used like this: .. need-example:: - .. needflow:: - :tags: flow_example - :types: spec - :config: lefttoright,my_config + .. needflow:: + :tags: flow_example + :types: spec + :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`: needs_graphviz_styles ~~~~~~~~~~~~~~~~~~~~~ @@ -765,30 +758,30 @@ These configs can then be selected when using :ref:`needflow` and the engine is .. code-block:: python - needs_graphviz_styles = { - "my_config": { - "graph": { - "rankdir": "LR", - "bgcolor": "transparent", - }, - "node": { - "fontname": "sans-serif", - "fontsize": 12, - }, - "edge": { - "color": "#57ACDC", - "fontsize": 10, - }, - } - } + 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 + .. 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. @@ -796,26 +789,26 @@ 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_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 ~~~~~~~~~~~~~~~~~~~~~ @@ -913,8 +906,6 @@ If you do not set ``needs_report_template``, the default template used is: {% endif %} {# Output for needs metrics #} - - The plugin provides the following variables which you can use in your custom Jinja template: * types - list of :ref:`need types ` @@ -938,21 +929,19 @@ valid: .. code-block:: python - 'node "YOUR_TEMPLATE" as need_id [[need_link]]' + 'node "YOUR_TEMPLATE" as need_id [[need_link]]' By default the following template is used: .. code-block:: jinja - {%- if is_need -%} - {{type_name}}\\n**{{title|wordwrap(15, wrapstring='**\\\\n**')}}**\\n{{id}} - {%- else -%} - {{type_name}} (part)\\n**{{content|wordwrap(15, wrapstring='**\\\\n**')}}**\\n{{id_parent}}.**{{id}}** - {%- endif -%} - - + {%- if is_need -%} + {{type_name}}\\n**{{title|wordwrap(15, wrapstring='**\\\\n**')}}**\\n{{id}} + {%- else -%} + {{type_name}} (part)\\n**{{content|wordwrap(15, wrapstring='**\\\\n**')}}**\\n{{id_parent}}.**{{id}}** + {%- endif -%} -.. _needs_id_required: +.. _`needs_id_required`: needs_id_required ~~~~~~~~~~~~~~~~~ @@ -965,7 +954,7 @@ So no ID is autogenerated any more, if this option is set to True: .. code-block:: python - needs_id_required = True + needs_id_required = True By default this option is set to **False**. @@ -973,19 +962,19 @@ By default this option is set to **False**. .. code-block:: rst - .. With needs_id_required = True + .. With needs_id_required = True - .. req:: Working Requirement - :id: R_001 + .. req:: Working Requirement + :id: R_001 - .. req:: **Not working**, because :id: is not set. + .. req:: **Not working**, because :id: is not set. - .. With needs_id_required = False + .. With needs_id_required = False - .. req:: This works now! + .. req:: This works now! -.. _needs_id_from_title: +.. _`needs_id_from_title`: needs_id_from_title ~~~~~~~~~~~~~~~~~~~ @@ -997,17 +986,17 @@ will be calculated based on the current need directive prefix, title, and a hash .. need-example:: - .. req:: Group big short - + .. req:: Group big short + The calculated need ID will be: `R_GROUP_BIG_SHORT_{hashed value}`, if the need ID length doesn't exceed the setting from :ref:`needs_id_length`. .. note:: - The user needs to ensure the uniqueness of the given title, and also match the settings of + The user needs to ensure the uniqueness of the given title, and also match the settings of :ref:`needs_id_length` and :ref:`needs_id_regex`. -.. _needs_title_optional: +.. _`needs_title_optional`: needs_title_optional ~~~~~~~~~~~~~~~~~~~~ @@ -1018,8 +1007,8 @@ Normally a title is required to follow the need directive as follows: .. code-block:: rst - .. req:: This is the required title - :id: R_9999 + .. req:: This is the required title + :id: R_9999 By default this option is set to **False**. @@ -1040,14 +1029,13 @@ sentence of the requirement. .. need-example:: - .. req:: - :title_from_content: - - This will be my title. Anything after the first sentence will not be - part of the title. + .. req:: + :title_from_content: + This will be my title. Anything after the first sentence will not be + part of the title. -.. _needs_title_from_content: +.. _`needs_title_from_content`: needs_title_from_content ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1070,16 +1058,15 @@ will be used over the generated title. .. need-example:: - .. req:: + .. req:: - The tool must have error logging. - All critical errors must be written to the console. + The tool must have error logging. + All critical errors must be written to the console. - -.. _needs_max_title_length: +.. _`needs_max_title_length`: needs_max_title_length -~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~ This option is used in conjunction with auto-generated titles as controlled by needs_title_from_content_ and :ref:`title_from_content`. By default, there is no @@ -1094,17 +1081,18 @@ still be used. Example: .. req:: - :title_from_content: + :title_from_content: - This is a requirement with a very long title that will need to be - shortened to prevent our titles from being too long. - Additional content can be provided in the requirement and not be part - of the title. + This is a requirement with a very long title that will need to be + shortened to prevent our titles from being too long. + Additional content can be provided in the requirement and not be part + of the title. -.. _needs_show_link_type: +.. _`needs_show_link_type`: needs_show_link_type ~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.1.27 This option mostly affects the roles :ref:`role_need_outgoing` and :ref:`role_need_incoming` by showing @@ -1116,12 +1104,13 @@ Activate it by setting it on True in your **conf.py**: .. code-block:: python - needs_show_link_type = True + needs_show_link_type = True -.. _needs_show_link_title: +.. _`needs_show_link_title`: needs_show_link_title ~~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.1.27 This option mostly affects the roles :ref:`role_need_outgoing` and :ref:`role_need_incoming` by showing @@ -1133,12 +1122,13 @@ Activate it by setting it on True in your **conf.py**: .. code-block:: python - needs_show_link_title = True + needs_show_link_title = True -.. _needs_show_link_id: +.. _`needs_show_link_id`: needs_show_link_id ~~~~~~~~~~~~~~~~~~ + .. versionadded:: 1.0.3 This option mostly affects the roles :ref:`role_need_outgoing` and :ref:`role_need_incoming` by showing @@ -1146,21 +1136,21 @@ the *ID* of the linked need. Can be combined with :ref:`needs_show_link_type` and :ref:`needs_show_link_title`. - .. code-block:: python - needs_show_link_id = True + needs_show_link_id = True -.. _needs_file: +.. _`needs_file`: needs_file ~~~~~~~~~~ + .. versionadded:: 0.1.30 Defines the location of a JSON file, which is used by the builder :ref:`needs_builder` as input source. Default value: *needs.json*. -.. _needs_statuses: +.. _`needs_statuses`: needs_statuses ~~~~~~~~~~~~~~ @@ -1175,17 +1165,17 @@ Activate it by setting it like this: .. code-block:: python - needs_statuses = [ - dict(name="open", description="Nothing done yet"), - dict(name="in progress", description="Someone is working on it"), - dict(name="implemented", description="Work is done and implemented"), - ] + needs_statuses = [ + dict(name="open", description="Nothing done yet"), + dict(name="in progress", description="Someone is working on it"), + dict(name="implemented", description="Work is done and implemented"), + ] If parameter is not set or set to *False*, no checks will be performed. Default value: *[]*. -.. _needs_tags: +.. _`needs_tags`: needs_tags ~~~~~~~~~~ @@ -1200,17 +1190,16 @@ Activate it by setting it like this: .. code-block:: python - needs_tags = [ - dict(name="new", description="new needs"), - dict(name="security", description="tag for security needs"), - ] + needs_tags = [ + dict(name="new", description="new needs"), + dict(name="security", description="tag for security needs"), + ] If parameter is not set or set to *[]*, no checks will be performed. Default value: *[]*. - -.. _needs_css: +.. _`needs_css`: needs_css ~~~~~~~~~ @@ -1230,22 +1219,20 @@ Use it like this: .. code-block:: python - needs_css = "blank.css" - + needs_css = "blank.css" To provide your own CSS file, the path must be absolute. Example: .. code-block:: python - import os + import os - conf_py_folder = os.path.dirname(__file__) - needs_css = os.path.join(conf_py_folder, "my_styles.css") + conf_py_folder = os.path.dirname(__file__) + needs_css = os.path.join(conf_py_folder, "my_styles.css") See :ref:`styles_css` for available CSS selectors and more. - -.. _needs_role_need_template: +.. _`needs_role_need_template`: needs_role_need_template ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1259,7 +1246,7 @@ By default a referenced need is described by the following string: .. code-block:: jinja - {title} ({id}) + {title} ({id}) By using ``needs_role_need_template`` this representation can be easily adjusted to own requirements. @@ -1267,12 +1254,12 @@ Here are some ideas, how it could be used inside the **conf.py** file: .. code-block:: python - needs_role_need_template = "[{id}]: {title}" - needs_role_need_template = "-{id}-" - needs_role_need_template = "{type}: {title} ({status})" - needs_role_need_template = "{title} ({tags})" - needs_role_need_template = "{title:*^20s} - {content:.30}" - needs_role_need_template = "[{id}] {title} ({status}) {type_name}/{type} - {tags} - {links} - {links_back} - {content}" + needs_role_need_template = "[{id}]: {title}" + needs_role_need_template = "-{id}-" + needs_role_need_template = "{type}: {title} ({status})" + needs_role_need_template = "{title} ({tags})" + needs_role_need_template = "{title:*^20s} - {content:.30}" + needs_role_need_template = "[{id}] {title} ({status}) {type_name}/{type} - {tags} - {links} - {links_back} - {content}" ``needs_role_need_template`` must be a string, which supports the following placeholders: @@ -1291,10 +1278,11 @@ Please see https://pyformat.info/ for more information. RST-attributes like ``**bold**`` are **not** supported. -.. _needs_role_need_max_title_length: +.. _`needs_role_need_max_title_length`: needs_role_need_max_title_length ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.3.14 Defines the maximum length of need title that is shown in need references. @@ -1308,13 +1296,14 @@ If set to -1 the title will never be shortened. .. code-block:: python - # conf.py - needs_role_need_max_title_length = 45 + # conf.py + needs_role_need_max_title_length = 45 -.. _needs_table_style: +.. _`needs_table_style`: needs_table_style ~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.2.0 Defines the default style for each table. Can be overridden for specific tables by setting parameter @@ -1322,8 +1311,8 @@ Defines the default style for each table. Can be overridden for specific tables .. code-block:: python - # conf.py - needs_table_style = "datatables" + # conf.py + needs_table_style = "datatables" Default value: ``"datatables"`` @@ -1332,11 +1321,11 @@ Supported values: * **table**: Default Sphinx table * **datatables**: Table with activated DataTables functions (Sort, search, export, ...). - -.. _needs_table_columns: +.. _`needs_table_columns`: needs_table_columns ~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.2.0 Defines the default columns for each table. Can be overridden for specific tables by setting parameter @@ -1344,8 +1333,8 @@ Defines the default columns for each table. Can be overridden for specific table .. code-block:: python - # conf.py - needs_table_columns = "title;status;tags" + # conf.py + needs_table_columns = "title;status;tags" Default value: ``"id;title;status;type;outgoing;tags"`` @@ -1359,7 +1348,7 @@ Supported values: * incoming * outgoing -.. _needs_id_regex: +.. _`needs_id_regex`: needs_id_regex ~~~~~~~~~~~~~~ @@ -1382,7 +1371,7 @@ The ID length must be at least 3 characters. If you change the regular expression, you should also set :ref:`needs_id_required` so that authors are forced to set an valid ID. -.. _needs_functions: +.. _`needs_functions`: needs_functions ~~~~~~~~~~~~~~~ @@ -1415,18 +1404,16 @@ It is better to use the following way in your **conf.py** file: .. code-block:: python - from sphinx_needs.api import add_dynamic_function + from sphinx_needs.api import add_dynamic_function - def my_function(app, need, needs, *args, **kwargs): - # Do magic here - return "some data" + def my_function(app, need, needs, *args, **kwargs): + # Do magic here + return "some data" - def setup(app): - add_dynamic_function(app, my_function) + def setup(app): + add_dynamic_function(app, my_function) - - -.. _needs_part_prefix: +.. _`needs_part_prefix`: needs_part_prefix ~~~~~~~~~~~~~~~~~ @@ -1445,11 +1432,11 @@ The default value contains an arrow right and a non breaking space. See :ref:`needtable_show_parts` for an example output. - -.. _needs_warnings: +.. _`needs_warnings`: needs_warnings -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~ + .. versionadded:: 0.5.0 ``needs_warnings`` allows the definition of warnings which all needs must avoid during a Sphinx build. @@ -1495,26 +1482,26 @@ Example output: .. code-block:: text - ... - looking for now-outdated files... none found - pickling environment... done - checking consistency... WARNING: Sphinx-Needs warnings were raised. See console / log output for details. + ... + looking for now-outdated files... none found + pickling environment... done + checking consistency... WARNING: Sphinx-Needs warnings were raised. See console / log output for details. - Checking Sphinx-Needs warnings - type_check: passed - invalid_status: failed - failed needs: 11 (STYLE_005, EX_ROW_1, EX_ROW_3, copy_2, clv_1, clv_2, clv_3, clv_4, clv_5, T_C3893, R_AD4A0) - used filter: status not in ["open", "in progress", "closed", "done"] and status is not None + Checking Sphinx-Needs warnings + type_check: passed + invalid_status: failed + failed needs: 11 (STYLE_005, EX_ROW_1, EX_ROW_3, copy_2, clv_1, clv_2, clv_3, clv_4, clv_5, T_C3893, R_AD4A0) + used filter: status not in ["open", "in progress", "closed", "done"] and status is not None - type_match: failed - failed needs: 1 (TC_001) - used filter: - done - ... + type_match: failed + failed needs: 1 (TC_001) + used filter: + done + ... Due to the nature of Sphinx logging, a sphinx-warning may be printed wherever in the log. -.. _needs_warnings_always_warn: +.. _`needs_warnings_always_warn`: needs_warnings_always_warn ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1528,7 +1515,7 @@ For example, set this option to True: .. code-block:: python - needs_warnings_always_warn = True + needs_warnings_always_warn = True Using Sphinx build command ``sphinx-build -M html {srcdir} {outdir} -w error.log``, all the :ref:`needs_warnings` not passed will be logged into a **error.log** file as you specified. @@ -1536,10 +1523,11 @@ logged into a **error.log** file as you specified. If you use ``sphinx-build -M html {srcdir} {outdir} -W -w error.log``, the first :ref:`needs_warnings` not passed will stop the build and be logged into the file error.log. -.. _needs_layouts: +.. _`needs_layouts`: needs_layouts ~~~~~~~~~~~~~ + .. versionadded:: 0.5.0 You can use ``needs_layouts`` to define custom grid-based layouts with custom data. @@ -1553,26 +1541,27 @@ Example: .. code-block:: python - needs_layouts = { - 'my_layout': { - 'grid': 'simple', - 'layout': { - 'head': ['my custom head'], - 'meta': ['my first meta line', - 'my second meta line'] - } - } - } + needs_layouts = { + 'my_layout': { + 'grid': 'simple', + 'layout': { + 'head': ['my custom head'], + 'meta': ['my first meta line', + 'my second meta line'] + } + } + } .. note:: **Sphinx-Needs** provides some default layouts. These layouts cannot be overwritten. See :ref:`layout list ` for more information. -.. _needs_default_layout: +.. _`needs_default_layout`: needs_default_layout -~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.5.0 ``needs_default_layout`` defines the layout to use by default. @@ -1584,12 +1573,13 @@ Default value of ``needs_default_layout`` is ``clean``. .. code-block:: python - needs_default_layout = 'my_own_layout' + needs_default_layout = 'my_own_layout' -.. _needs_default_style: +.. _`needs_default_style`: needs_default_style ~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.5.0 The value of ``needs_default_style`` is used as default value for each need which does not define its own @@ -1601,7 +1591,6 @@ See :ref:`styles` for a list of default style names. needs_default_layout = 'border_yellow' - A combination of multiple styles is possible: .. code-block:: python @@ -1610,7 +1599,7 @@ A combination of multiple styles is possible: Custom values can be set as well, if your projects provides the needed CSS-files for it. -.. _needs_template_folder: +.. _`needs_template_folder`: needs_template_folder ~~~~~~~~~~~~~~~~~~~~~ @@ -1624,7 +1613,7 @@ The folder must already exist, otherwise an exception gets thrown, if a need tri Read also :ref:`need_template option description ` for information of how to use templates. -.. _needs_duration_option: +.. _`needs_duration_option`: needs_duration_option ~~~~~~~~~~~~~~~~~~~~~ @@ -1637,7 +1626,7 @@ See also :ref:`needgantt_duration_option`, which overrides this value for specif Default: :ref:`need_duration` -.. _needs_completion_option: +.. _`needs_completion_option`: needs_completion_option ~~~~~~~~~~~~~~~~~~~~~~~ @@ -1650,7 +1639,7 @@ See also :ref:`needgantt_completion_option`, which overrides this value for spec Default: :ref:`need_completion` -.. _needs_services: +.. _`needs_services`: needs_services ~~~~~~~~~~~~~~ @@ -1661,18 +1650,18 @@ Takes extra configuration options for :ref:`services`: .. code-block:: python - needs_services = { - 'jira': { - 'url': 'my_jira_server.com', - }, - 'git': { - 'url': 'my_git_server.com', - }, - 'my_service': { - 'class': MyServiceClass, - 'config_1': 'value_x', - } - } + needs_services = { + 'jira': { + 'url': 'my_jira_server.com', + }, + 'git': { + 'url': 'my_git_server.com', + }, + 'my_service': { + 'class': MyServiceClass, + 'config_1': 'value_x', + } + } Each key-value-pair in ``needs_services`` describes a service specific configuration. @@ -1682,7 +1671,7 @@ Config options are service specific and are described by :ref:`services`. See also :ref:`needservice`. -.. _needs_service_all_data: +.. _`needs_service_all_data`: needs_service_all_data ~~~~~~~~~~~~~~~~~~~~~~ @@ -1696,10 +1685,9 @@ Default: ``False``. .. code-block:: python - needs_service_all_data = True - + needs_service_all_data = True -.. _needs_import_keys: +.. _`needs_import_keys`: needs_import_keys ~~~~~~~~~~~~~~~~~ @@ -1708,7 +1696,7 @@ needs_import_keys For use with the :ref:`needimport` directive, mapping keys to file paths, see :ref:`needimport-keys`. -.. _needs_external_needs: +.. _`needs_external_needs`: needs_external_needs ~~~~~~~~~~~~~~~~~~~~ @@ -1720,83 +1708,83 @@ Allows to reference and use external needs without having their representation i .. code-block:: python - needs_external_needs = [ - { - 'base_url': 'http://mydocs/my_project', - 'json_url': 'http://mydocs/my_project/needs.json', - 'version': '1.0', - 'id_prefix': 'ext_', - 'css_class': 'external_link', - }, - { - 'base_url': 'http://mydocs/another_project/', - 'json_path': 'my_folder/needs.json', - 'version': '2.5', - 'id_prefix': 'other_', - 'css_class': 'project_x', - }, - { - 'base_url': '/', - 'json_path': 'my_folder/needs.json', - 'version': '2.5', - 'id_prefix': 'ext_', - 'css_class': 'project_x', - }, - { - "base_url": "http://my_company.com/docs/v1/", - "target_url": "issue/{{need['id']}}", - "json_path": "needs_test.json", - "id_prefix": "ext_need_id_", - }, - { - "base_url": "http://my_company.com/docs/v1/", - "target_url": "issue/{{need['type']|upper()}}", - "json_path": "needs_test.json", - "id_prefix": "ext_need_type_", - }, - { - "base_url": "http://my_company.com/docs/v1/", - "target_url": "issue/fixed_string", - "json_path": "needs_test.json", - "id_prefix": "ext_string_", - }, - ] + needs_external_needs = [ + { + 'base_url': 'http://mydocs/my_project', + 'json_url': 'http://mydocs/my_project/needs.json', + 'version': '1.0', + 'id_prefix': 'ext_', + 'css_class': 'external_link', + }, + { + 'base_url': 'http://mydocs/another_project/', + 'json_path': 'my_folder/needs.json', + 'version': '2.5', + 'id_prefix': 'other_', + 'css_class': 'project_x', + }, + { + 'base_url': '/', + 'json_path': 'my_folder/needs.json', + 'version': '2.5', + 'id_prefix': 'ext_', + 'css_class': 'project_x', + }, + { + "base_url": "http://my_company.com/docs/v1/", + "target_url": "issue/{{need['id']}}", + "json_path": "needs_test.json", + "id_prefix": "ext_need_id_", + }, + { + "base_url": "http://my_company.com/docs/v1/", + "target_url": "issue/{{need['type']|upper()}}", + "json_path": "needs_test.json", + "id_prefix": "ext_need_type_", + }, + { + "base_url": "http://my_company.com/docs/v1/", + "target_url": "issue/fixed_string", + "json_path": "needs_test.json", + "id_prefix": "ext_string_", + }, + ] ``needs_external_needs`` must be a list of dictionary elements and each dictionary must/can have the following keys: :base_url: Base url which is used to calculate the final, specific need url. Normally the path under which the ``index.html`` is provided. - Base url supports also relative path, which starts from project build html folder (normally where ``index.html`` is located). + Base url supports also relative path, which starts from project build html folder (normally where ``index.html`` is located). :target_url: Allows to config the final caculated need url. (*optional*) - |br| If provided, ``target_url`` will be appended to ``base_url`` as the final calculate need url, e.g. ``base_url/target_url``. - If not, the external need url uses the default calculated ``base_url``. - |br| The ``target_url`` supports Jinja context ``{{need[]}}``, ``need option`` used as key, e.g ``{{need['id']}}`` or ``{{need['type']}}``. + |br| If provided, ``target_url`` will be appended to ``base_url`` as the final calculate need url, e.g. ``base_url/target_url``. + If not, the external need url uses the default calculated ``base_url``. + |br| The ``target_url`` supports Jinja context ``{{need[]}}``, ``need option`` used as key, e.g ``{{need['id']}}`` or ``{{need['type']}}``. :json_url: An url, which can be used to download the ``needs.json`` (or similar) file. :json_path: The path to a ``needs.json`` file located inside your documentation project. Can not be used together with - ``json_url``. |br| The value must be a relative path, which is relative to the project configuration folder - (where the **conf.py** is stored). (Since version `0.7.1`) + ``json_url``. |br| The value must be a relative path, which is relative to the project configuration folder + (where the **conf.py** is stored). (Since version `0.7.1`) :version: Defines the version to use inside the ``needs.json`` file (*optional*). :id_prefix: Prefix as string, which will be added to all id of external needs. Needed, if there is the risk that - needs from different projects may have the same id (*optional*). + needs from different projects may have the same id (*optional*). :css_class: A class name as string, which gets set in link representations like :ref:`needtable`. - The related CSS class definition must be done by the user, e.g. by :ref:`own_css`. - (*optional*) (*default*: ``external_link``) - + The related CSS class definition must be done by the user, e.g. by :ref:`own_css`. + (*optional*) (*default*: ``external_link``) - -.. _needs_needextend_strict: +.. _`needs_needextend_strict`: needs_needextend_strict ~~~~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 1.0.3 ``needs_needextend_strict`` allows you to deactivate or activate the :ref:`strict ` option behaviour for all :ref:`needextend` directives. -.. _needs_table_classes: +.. _`needs_table_classes`: needs_table_classes ~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.7.2 Allows to define custom CSS classes which get set for the HTML tables of ``need`` and ``needtable``. @@ -1809,10 +1797,11 @@ This may be needed to avoid custom table handling of some specific Sphinx theme These classes are not set for needtables using the ``table`` style, which is using the normal Sphinx table layout and therefore must be handled by themes. -.. _needs_builder_filter: +.. _`needs_builder_filter`: needs_builder_filter ~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.7.2 Defines a :ref:`filter_string` used to filter needs for the builder :ref:`needs_builder`. @@ -1824,10 +1813,11 @@ Need objects imported via :ref:`needs_external_needs` get sorted out. needs_builder_filter = 'status=="open"' -.. _needs_string_links: +.. _`needs_string_links`: needs_string_links ~~~~~~~~~~~~~~~~~~ + .. versionadded:: 0.7.4 Transforms a given option value to a link. @@ -1836,14 +1826,14 @@ Helpful e.g. to generate a link to a ticket system based on the given ticket num .. code-block:: python - needs_string_links = { - 'custom_name': { - 'regex': "...", - 'link_url' : "...", - 'link_name': '...' - 'options': ['status', '...'] - } - } + needs_string_links = { + 'custom_name': { + 'regex': "...", + 'link_url' : "...", + 'link_name': '...' + 'options': ['status', '...'] + } + } :regex: Must be a valid regular expression. Named capture groups are supported. :link_url: The final url as string. Supports Jinja. @@ -1856,28 +1846,26 @@ link name and url. **Example**: - .. code-block:: python - # conf.py - - needs_string_links = { - # Adds link to the Sphinx-Needs configuration page - 'config_link': { - 'regex': r'^(?P\w+)$', - 'link_url': 'https://sphinx-needs.readthedocs.io/en/latest/configuration.html#{{value | replace("_", "-")}}', - 'link_name': 'Sphinx-Needs docs for {{value | replace("_", "-") }}', - 'options': ['config'] - }, - # Links to the related github issue - 'github_link': { - 'regex': r'^(?P\w+)$', - 'link_url': 'https://github.com/useblocks/sphinx-needs/issues/{{value}}', - 'link_name': 'GitHub #{{value}}', - 'options': ['github'] - } - } + # conf.py + needs_string_links = { + # Adds link to the Sphinx-Needs configuration page + 'config_link': { + 'regex': r'^(?P\w+)$', + 'link_url': 'https://sphinx-needs.readthedocs.io/en/latest/configuration.html#{{value | replace("_", "-")}}', + 'link_name': 'Sphinx-Needs docs for {{value | replace("_", "-") }}', + 'options': ['config'] + }, + # Links to the related github issue + 'github_link': { + 'regex': r'^(?P\w+)$', + 'link_url': 'https://github.com/useblocks/sphinx-needs/issues/{{value}}', + 'link_name': 'GitHub #{{value}}', + 'options': ['github'] + } + } .. need-example:: @@ -1888,11 +1876,9 @@ link name and url. Replaces the string from ``:config:`` and ``:github:`` with a link to the related website. -.. note:: - - You must define the options specified under :ref:`needs_string_links` inside :ref:`needs_extra_options` as well. +.. note:: You must define the options specified under :ref:`needs_string_links` inside :ref:`needs_extra_options` as well. -.. _needs_build_json: +.. _`needs_build_json`: needs_build_json ~~~~~~~~~~~~~~~~ @@ -1904,7 +1890,7 @@ Builds a ``needs.json`` file during other builds, like ``html``. This allows to have one single Sphinx-Build for two output formats, which may save some time. All other ``needs.json`` related configuration values, like :ref:`needs_file`, -:ref:`needs_build_json_per_id` and :ref:`needs_json_remove_defaults` +:ref:`needs_build_json_per_id` and :ref:`needs_json_remove_defaults` are taken into account. Default: False @@ -1913,7 +1899,7 @@ Example: .. code-block:: python - needs_build_json = True + needs_build_json = True .. hint:: @@ -1922,9 +1908,9 @@ Example: See :ref:`this section `, for an explanation of the output format. -.. _needs_reproducible_json: +.. _`needs_reproducible_json`: -needs_reproducible_json +needs_reproducible_json ~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 2.0.0 @@ -1932,7 +1918,7 @@ needs_reproducible_json Setting ``needs_reproducible_json = True`` will ensure the ``needs.json`` output is reproducible, e.g. by removing timestamps from the output. -.. _needs_json_exclude_fields: +.. _`needs_json_exclude_fields`: needs_json_exclude_fields ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1943,9 +1929,9 @@ Setting ``needs_json_exclude_fields = ["key1", "key2"]`` will exclude the given Default: :need_config_default:`json_exclude_fields` -.. _needs_json_remove_defaults: +.. _`needs_json_remove_defaults`: -needs_json_remove_defaults +needs_json_remove_defaults ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 2.1.0 @@ -1956,15 +1942,15 @@ The defaults can be retrieved from the ``needs_schema`` now also output in the J Default: :need_config_default:`json_remove_defaults` -.. _needs_build_json_per_id: +.. _`needs_build_json_per_id`: -needs_build_json_per_id +needs_build_json_per_id ~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 2.0.0 - + Builds list json files for each need. The name of each file is the ``id`` of need. -This option works like :ref:`needs_build_json`. +This option works like :ref:`needs_build_json`. Default: False @@ -1972,19 +1958,17 @@ Example: .. code-block:: python - needs_build_json_per_id = False + needs_build_json_per_id = False -.. hint:: +.. hint:: The created single json file per need, located in :ref:`needs_build_json_per_id_path` folder, e.g ``_build/needs_id/abc_432.json`` - The created single json file per need, located in :ref:`needs_build_json_per_id_path` folder, e.g ``_build/needs_id/abc_432.json`` - -.. _needs_build_json_per_id_path: +.. _`needs_build_json_per_id_path`: -needs_build_json_per_id_path +needs_build_json_per_id_path ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 2.0.0 - + This option sets the location of the set of ``needs.json`` for every needs-id. Default value: ``needs_id`` @@ -1993,13 +1977,11 @@ Example: .. code-block:: python - needs_build_json_per_id_path = "needs_id" + needs_build_json_per_id_path = "needs_id" -.. hint:: +.. hint:: The created ``needs_id`` folder gets stored in the ``outdir`` of the current builder. The final location is e.g. ``_build/needs_id`` - The created ``needs_id`` folder gets stored in the ``outdir`` of the current builder. The final location is e.g. ``_build/needs_id`` - -.. _needs_build_needumls: +.. _`needs_build_needumls`: needs_build_needumls ~~~~~~~~~~~~~~~~~~~~ @@ -2016,11 +1998,11 @@ Example: .. code-block:: python - needs_build_needumls = "my_needumls" + needs_build_needumls = "my_needumls" As a result, all the :ref:`needuml` data will be exported into folder in the ``outdir`` of the current builder, e.g. ``_build/html/my_needumls/``. -.. _needs_permalink_file: +.. _`needs_permalink_file`: needs_permalink_file ~~~~~~~~~~~~~~~~~~~~ @@ -2031,13 +2013,13 @@ which will be copied to the html build directory during build. The permalink web site will load a ``needs.json`` file as specified by :ref:`needs_permalink_data` and re-direct the web browser to the html document of the need, which is specified by appending the need ID as a query -parameter, e.g., ``http://localhost:8000/permalink.html?id=REQ_4711``. +parameter, e.g., ``http://localhost:8000/permalink.html?id=REQ_4711``. Example: .. code-block:: python - needs_permalink_file = "my_permalink.html" + needs_permalink_file = "my_permalink.html" Results in a file ``my_permalink.html`` in the html build directory. @@ -2046,13 +2028,13 @@ available at ``http://localhost:8000/my_permalink.html``. Default value: ``permalink.html`` -.. _needs_permalink_data: +.. _`needs_permalink_data`: needs_permalink_data ~~~~~~~~~~~~~~~~~~~~ -This options sets the location of a ``needs.json`` file. -This file is used to create permanent links for needs as described +This options sets the location of a ``needs.json`` file. +This file is used to create permanent links for needs as described in :ref:`needs_permalink_file`. The path can be a relative path (relative to the permalink html file), @@ -2060,8 +2042,7 @@ an absolute path (on the web server) or an URL. Default value: ``needs.json`` - -.. _needs_constraints: +.. _`needs_constraints`: needs_constraints ~~~~~~~~~~~~~~~~~ @@ -2070,25 +2051,25 @@ needs_constraints .. code-block:: python - needs_constraints = { + needs_constraints = { - "critical": { - "check_0": "'critical' in tags", - "check_1": "'security_req' in links", - "severity": "CRITICAL" - }, + "critical": { + "check_0": "'critical' in tags", + "check_1": "'security_req' in links", + "severity": "CRITICAL" + }, - "security": { - "check_0": "'security' in tags", - "severity": "HIGH" - }, + "security": { + "check_0": "'security' in tags", + "severity": "HIGH" + }, - "team": { - "check_0": "author == \"Bob\"", - "severity": "LOW" - }, + "team": { + "check_0": "author == \"Bob\"", + "severity": "LOW" + }, - } + } needs_constraints needs to be enabled by adding "constraints" to :ref:`needs_extra_options` @@ -2104,94 +2085,92 @@ constraints_results is a dictionary similar in structure to needs_constraints ab .. versionadded:: 2.0.0 - The ``"error_message"`` key can contain a string, with Jinja templating, which will be displayed if the constraint fails, and saved on the need as ``constraints_error``: + The ``"error_message"`` key can contain a string, with Jinja templating, which will be displayed if the constraint fails, and saved on the need as ``constraints_error``: - .. code-block:: python + .. code-block:: python - needs_constraints = { + needs_constraints = { - "critical": { - "check_0": "'critical' in tags", - "severity": "CRITICAL", - "error_message": "need {{id}} does not fulfill CRITICAL constraint, because tags are {{tags}}" - } - - } + "critical": { + "check_0": "'critical' in tags", + "severity": "CRITICAL", + "error_message": "need {{id}} does not fulfill CRITICAL constraint, because tags are {{tags}}" + } + } .. code-block:: rst - .. req:: - :id: SECURITY_REQ - - This is a requirement describing security processes. + .. req:: + :id: SECURITY_REQ - .. req:: - :tags: critical - :links: SECURITY_REQ - :constraints: critical + This is a requirement describing security processes. - Example of a successful constraint. + .. req:: + :tags: critical + :links: SECURITY_REQ + :constraints: critical - .. req:: - :id: FAIL_01 - :author: "Alice" - :constraints: team + Example of a successful constraint. - Example of a failed constraint with medium severity. Note the style from :ref:`needs_constraint_failed_options` + .. req:: + :id: FAIL_01 + :author: "Alice" + :constraints: team + Example of a failed constraint with medium severity. Note the style from :ref:`needs_constraint_failed_options` .. req:: - :id: SECURITY_REQ + :id: SECURITY_REQ - This is a requirement describing security processes. + This is a requirement describing security processes. .. req:: - :tags: critical - :links: SECURITY_REQ - :constraints: critical + :tags: critical + :links: SECURITY_REQ + :constraints: critical - Example of a successful constraint. + Example of a successful constraint. .. req:: - :id: FAIL_01 - :author: "Alice" - :constraints: team + :id: FAIL_01 + :author: "Alice" + :constraints: team - Example of a failed constraint with medium severity. Note the style from :ref:`needs_constraint_failed_options` + Example of a failed constraint with medium severity. Note the style from :ref:`needs_constraint_failed_options` -.. _needs_constraint_failed_options: +.. _`needs_constraint_failed_options`: needs_constraint_failed_options ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - needs_constraint_failed_options = { - "CRITICAL": { - "on_fail": ["warn"], - "style": ["red_bar"], - "force_style": True - }, - - "HIGH": { - "on_fail": ["warn"], - "style": ["orange_bar"], - "force_style": True - }, - - "MEDIUM": { - "on_fail": ["warn"], - "style": ["yellow_bar"], - "force_style": False - }, - - "LOW": { - "on_fail": [], - "style": ["yellow_bar"], - "force_style": False - } - } + needs_constraint_failed_options = { + "CRITICAL": { + "on_fail": ["warn"], + "style": ["red_bar"], + "force_style": True + }, + + "HIGH": { + "on_fail": ["warn"], + "style": ["orange_bar"], + "force_style": True + }, + + "MEDIUM": { + "on_fail": ["warn"], + "style": ["yellow_bar"], + "force_style": False + }, + + "LOW": { + "on_fail": [], + "style": ["yellow_bar"], + "force_style": False + } + } needs_constraint_failed_options must be a dictionary which stores what to do if a certain constraint fails. Dictionary keys correspond to the severity set when creating a constraint. @@ -2205,10 +2184,11 @@ Each entry describes in an "on_fail" action what to do: If "force style" is set, all other styles are removed and just the constraint_failed style remains. -.. _needs_variants: +.. _`needs_variants`: needs_variants ~~~~~~~~~~~~~~ + .. versionadded:: 1.0.2 ``needs_variants`` configuration option must be a dictionary which has pre-defined variants assigned to @@ -2228,10 +2208,11 @@ The value is a string which consists of a Python-supported "filter string". Default: ``{}`` -.. _needs_variant_options: +.. _`needs_variant_options`: needs_variant_options ~~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 1.0.2 ``needs_variant_options`` must be a list which consists of the options to apply variants handling. @@ -2259,10 +2240,11 @@ Default: ``[]`` - :ref:`extra links `. 2. By default, if ``needs_variant_options`` is empty, we deactivate variants handling for need options. -.. _needs_render_context: +.. _`needs_render_context`: needs_render_context -~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 1.0.3 This option allows you to use custom data as context when rendering `Jinja `__ templates or strings. @@ -2271,45 +2253,49 @@ Configuration example: .. code-block:: python - def custom_defined_func(): - return "my_tag" + def custom_defined_func(): + return "my_tag" - needs_render_context = { - "custom_data_1": "Project_X", - "custom_data_2": custom_defined_func(), - "custom_data_3": True, - "custom_data_4": [("Daniel", 811982), ("Marco", 234232)], - } + needs_render_context = { + "custom_data_1": "Project_X", + "custom_data_2": custom_defined_func(), + "custom_data_3": True, + "custom_data_4": [("Daniel", 811982), ("Marco", 234232)], + } The``needs_render_context`` configuration option must be a dictionary. The dictionary consists of key-value pairs where the key is a string used as reference to the value. The value can be any data type (string, integer, list, dict, etc.) -.. warning:: The value can also be a custom defined function, - however, this will deactivate the caching and incremental build feature of Sphinx. +.. warning:: + + The value can also be a custom defined function, + however, this will deactivate the caching and incremental build feature of Sphinx. The data passed via needs_render_context will be available as variable(s) when rendering Jinja templates or strings. You can use the data passed via needs_render_context as shown below: .. need-example:: - .. req:: Need with jinja_content enabled - :id: JINJA1D8913 - :jinja_content: true + .. req:: Need with jinja_content enabled + :id: JINJA1D8913 + :jinja_content: true - Need with alias {{ custom_data_1 }} and ``jinja_content`` option set to {{ custom_data_3 }}. + Need with alias {{ custom_data_1 }} and ``jinja_content`` option set to {{ custom_data_3 }}. - {{ custom_data_2 }} - {% for author in custom_data_4 %} - * author[0] - + author[1] - {% endfor %} + {{ custom_data_2 }} + {% for author in custom_data_4 %} + * author[0] + + author[1] -.. _needs_debug_measurement: + {% endfor %} + +.. _`needs_debug_measurement`: needs_debug_measurement ~~~~~~~~~~~~~~~~~~~~~~~ + .. versionadded:: 1.3.0 Activates :ref:`runtime_debugging`, which measures the execution time of different functions and creates a helpful @@ -2319,9 +2305,9 @@ See :ref:`runtime_debugging` for details. To activate it, set it to ``True``:: - needs_debug_measurement = True + needs_debug_measurement = True -.. _needs_debug_filters: +.. _`needs_debug_filters`: needs_debug_filters ~~~~~~~~~~~~~~~~~~~ From 7a904144dfe42db3a7419df87764d246688463d8 Mon Sep 17 00:00:00 2001 From: Marco Heinemann Date: Thu, 24 Jul 2025 21:25:48 +0200 Subject: [PATCH 12/12] =?UTF-8?q?=E2=9C=A8=20Schema=20validation=20(#1467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the implementation of the discussion https://github.com/useblocks/sphinx-needs/discussions/1451 and a follow-up on https://github.com/useblocks/sphinx-needs/pull/1441. The PR adds schema validation to Sphinx-Needs that supports local validation and network validation. The Sphinx-Needs internal data type representation is not changed yet, all types are still strings. This will come shortly after merge. Users can already try out the new interface and provide feedback. Differences to PR 1441: - Aligning with standards in JSON schema for re-using subschemas via `$defs` and `$ref` - Fully typed implementation including runtime checks of valid schema user input - Auto inject the default string type if not given - Replace `trigger_schema` with `select` which aligns with query language terminology - Replace `trigger_schema_id` with the mentioned `$ref` mechanism - New schemas root key `validate` with sub-keys `local` and `network` for the 2 validation types - Network validation items does not allow the `select` key anymore as the selection happens by linking target needs. This cleans up an ambiguity in the other PR. - Network validation errors now bubble up to the root json schema and are displayed to see exactly why the chain fails - More network rule types for better control over debug schema output - Rewrite test cases to use a declarative definition as yaml, so all pieces can be given in one place: - conf.py - ubproject.toml - index.rst - schemas.json - expected ontology warnings - Simplified the code logic - String patterns (regex) are constrained to a basic subset that works across engines - Added docs - Examples and explanations - Comparison with `needs_warnings` and `needs_constraints` and migration path - Many more test cases - `items` with `minItems` and `maxItems` and `contains` with `minContains` and `maxContains` are now semantically equivalent to JSON schema spec --------- Co-authored-by: Chris Sewell --- .pre-commit-config.yaml | 1 + docs/configuration.rst | 192 +++ docs/index.rst | 1 + docs/schema/01_basic_setup.drawio.png | Bin 0 -> 60447 bytes docs/schema/02_local_check.drawio.png | Bin 0 -> 95419 bytes docs/schema/03_network_check.drawio.png | Bin 0 -> 73739 bytes docs/schema/index.rst | 1041 +++++++++++++++++ pyproject.toml | 6 +- sphinx_needs/builder.py | 44 +- sphinx_needs/config.py | 98 +- sphinx_needs/environment.py | 6 +- sphinx_needs/exceptions.py | 4 + sphinx_needs/logging.py | 45 +- sphinx_needs/needs.py | 66 +- sphinx_needs/schema/__init__.py | 0 sphinx_needs/schema/config.py | 439 +++++++ sphinx_needs/schema/config_utils.py | 613 ++++++++++ sphinx_needs/schema/core.py | 700 +++++++++++ sphinx_needs/schema/process.py | 94 ++ sphinx_needs/schema/reporting.py | 284 +++++ sphinx_needs/schema/utils.py | 25 + .../test_schema_benchmark.ambr | 724 ++++++++++++ .../test_schema_benchmark.ambr | 724 ++++++++++++ tests/benchmarks/test_schema_benchmark.py | 120 ++ tests/conftest.py | 143 ++- tests/doc_test/doc_schema_benchmark/conf.py | 12 + .../doc_test/doc_schema_benchmark/page.rst.j2 | 56 + .../doc_schema_benchmark/schemas.json | 148 +++ .../doc_schema_benchmark/ubproject.toml | 55 + tests/doc_test/doc_schema_e2e/conf.py | 15 + tests/doc_test/doc_schema_e2e/index.rst | 65 + tests/doc_test/doc_schema_e2e/schemas.json | 146 +++ tests/doc_test/doc_schema_e2e/ubproject.toml | 63 + tests/schema/fixtures/config.yml | 572 +++++++++ tests/schema/fixtures/extra_links.yml | 341 ++++++ tests/schema/fixtures/extra_options.yml | 982 ++++++++++++++++ tests/schema/fixtures/network.yml | 669 +++++++++++ tests/schema/fixtures/unevaluated.yml | 63 + tests/schema/test_schema.py | 130 ++ 39 files changed, 8651 insertions(+), 36 deletions(-) create mode 100644 docs/schema/01_basic_setup.drawio.png create mode 100644 docs/schema/02_local_check.drawio.png create mode 100644 docs/schema/03_network_check.drawio.png create mode 100644 docs/schema/index.rst create mode 100644 sphinx_needs/schema/__init__.py create mode 100644 sphinx_needs/schema/config.py create mode 100644 sphinx_needs/schema/config_utils.py create mode 100644 sphinx_needs/schema/core.py create mode 100644 sphinx_needs/schema/process.py create mode 100644 sphinx_needs/schema/reporting.py create mode 100644 sphinx_needs/schema/utils.py create mode 100644 tests/benchmarks/__snapshots__sphinx_ge_8/test_schema_benchmark.ambr create mode 100644 tests/benchmarks/__snapshots__sphinx_lt_8/test_schema_benchmark.ambr create mode 100644 tests/benchmarks/test_schema_benchmark.py create mode 100644 tests/doc_test/doc_schema_benchmark/conf.py create mode 100644 tests/doc_test/doc_schema_benchmark/page.rst.j2 create mode 100644 tests/doc_test/doc_schema_benchmark/schemas.json create mode 100644 tests/doc_test/doc_schema_benchmark/ubproject.toml create mode 100644 tests/doc_test/doc_schema_e2e/conf.py create mode 100644 tests/doc_test/doc_schema_e2e/index.rst create mode 100644 tests/doc_test/doc_schema_e2e/schemas.json create mode 100644 tests/doc_test/doc_schema_e2e/ubproject.toml create mode 100644 tests/schema/fixtures/config.yml create mode 100644 tests/schema/fixtures/extra_links.yml create mode 100644 tests/schema/fixtures/extra_options.yml create mode 100644 tests/schema/fixtures/network.yml create mode 100644 tests/schema/fixtures/unevaluated.yml create mode 100644 tests/schema/test_schema.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e724ba1d6..6d0a31f54 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,7 @@ repos: - types-docutils==0.20.0.20240201 - types-jsonschema - types-requests + - typeguard # TODO this does not work on pre-commit.ci # - repo: https://github.com/astral-sh/uv-pre-commit diff --git a/docs/configuration.rst b/docs/configuration.rst index e2fbf102a..8e49db597 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -2316,3 +2316,195 @@ needs_debug_filters If set to ``True``, all calls to :ref:`filter processing ` will be logged to a ``debug_filters.jsonl`` file in the build output directory, appending a single-line JSON for each filter call. + +.. _`needs_schema_definitions`: + +needs_schema_definitions +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 6.0.0 + +Defines validation schemas for needs using a definition format derived from JSON Schema. +Schemas can be used to validate need fields, enforce constraints, and ensure data consistency. + +Default value: ``{}`` + +.. code-block:: python + + needs_schema_definitions = { + "$defs": { + "type-spec": { + "properties": {"type": {"const": "spec"}} + }, + "safe-need": { + "properties": {"asil": {"enum": ["A", "B", "C", "D"]}}, + "required": ["asil"] + } + }, + "schemas": [ + { + "id": "unique-id-validation", + "severity": "warning", + "message": "ID must be uppercase", + "validate": { + "local": { + "properties": { + "id": {"pattern": "^[A-Z0-9_]+$"} + } + } + } + }, + { + "id": "safe-spec", + "validate": { + "local": { + "allOf": [ + {"$ref": "#/$defs/type-spec"}, + {"$ref": "#/$defs/safe-need"} + ] + } + } + } + ] + } + +See :ref:`schema_validation` for detailed documentation. + +.. _`needs_schema_definitions_from_json`: + +needs_schema_definitions_from_json +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 6.0.0 + +Path to a JSON file containing schema definitions. This is the recommended approach for +defining schemas as it provides better IDE support and maintainability. + +Default value: ``None`` + +.. code-block:: python + + needs_schema_definitions_from_json = "schemas.json" + +The JSON file should contain the same structure as :ref:`needs_schema_definitions`: + +.. _`needs_schema_severity`: + +needs_schema_severity +~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 6.0.0 + +Minimum severity level for schema validation reporting. +Extra option and extra link schema errors are always reported as violations. +For each entry in :ref:`needs_schema_definitions` the severity can be defined by the user. + +Available severity levels: + +- ``info``: Informational message (default) +- ``warning``: Warning message +- ``violation``: Violation message + +The levels align with how `SHACL `__ defines severity levels. + +Default value: ``"info"`` + +.. code-block:: python + + needs_schema_severity = "warning" + +.. _`needs_schema_debug_active`: + +needs_schema_debug_active +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 6.0.0 + +Activates debug mode for schema validation. When enabled, the validation dumps JSON files, +schema files, and validation messages to help troubleshoot schema validation issues. + +Default value: ``False`` + +.. code-block:: python + + needs_schema_debug_active = True + +Debug files are written to the directory specified by :ref:`needs_schema_debug_path`. + +.. _`needs_schema_debug_path`: + +needs_schema_debug_path +~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 6.0.0 + +Directory path where schema debug files are stored when :ref:`needs_schema_debug_active` is +enabled. + +If the path is relative, it will be resolved relative to the ``conf.py`` directory. + +Default value: ``"schema_debug"`` + +.. code-block:: python + + needs_schema_debug_path = "debug/schemas" + +.. _`needs_schema_debug_ignore`: + +needs_schema_debug_ignore +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 6.0.0 + +List of validation scenarios to ignore when dumping debug information. This helps reduce +noise in debug output by filtering out irrelevant validations. + +Default value:: + + [ + "extra_option_success", + "extra_link_success", + "select_success", + "select_fail", + "local_success", + "network_local_success" + ] + +.. code-block:: python + + needs_schema_debug_ignore = [ + "extra_option_success", + "local_success", + "network_local_success" + ] + +To write all scenarios, set it to an empty list: ``[]``. + +Available scenarios that can be ignored: + +- ``cfg_schema_error``: The user provided schema is invalid +- ``extra_option_type_error``: A need extra option cannot be coerced to the type specified in the schema +- ``extra_option_success``: Global extra option validation was successful +- ``extra_option_fail``: Global extra option validation failed +- ``extra_link_success``: Global extra link validations was successful +- ``extra_link_fail``: Global extra link validation failed +- ``select_success``: Successful select validation +- ``select_fail``: Failed select validation +- ``local_success``: Successful local validation +- ``local_fail``: Need local validation failed +- ``network_missing_target``: An outgoing link target cannot be resolved +- ``network_contains_too_few``: minContains or minItems validation failed for the given link_schema link type +- ``network_contains_too_many``: maxContains or maxItems validation failed for the given link_schema link type +- ``network_items_fail``: items validation failed for the given link_schema link type +- ``network_local_success``: Successful network local validation +- ``network_local_fail``: Need does not validate against the local schema in a network context +- ``network_max_nest_level``: The maximum nesting level of network links was exceeded + +The debug information is written to the directory specified by :ref:`needs_schema_debug_path`. +The ``_success`` scenarios exist to analyze why a validation was successful and how the +final need and schema looks like. + +.. note:: + + For large need counts, the debug output can become very large. + Writing debug output also affects the validation performance negatively. diff --git a/docs/index.rst b/docs/index.rst index e71c75452..092aa31ba 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -208,6 +208,7 @@ Contents services/index layout_styles api + schema/index utils .. toctree:: diff --git a/docs/schema/01_basic_setup.drawio.png b/docs/schema/01_basic_setup.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..883b8ef6af4a87db0ec64ff9a6db8d896fa9cf9d GIT binary patch literal 60447 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%0A?EDZe4ugu4L>Jml7&VT(C`<^$okDe3H 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 literal 0 HcmV?d00001 diff --git a/docs/schema/02_local_check.drawio.png b/docs/schema/02_local_check.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..96ffcb5cc2662da7cf6c978368b705402ce55f7e GIT binary patch literal 95419 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

CloJ-f(c9ND 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%0D^;BifvscQ-Hg>Tx_#K7rZYAVMgi&3s%4O0v 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�-@Y=f-L8HnUtdj~2o|HiPDb~*uYqR%l1J5Dj+_1;+aOb#%kM}; zyJdx4k%Rrncb;=Uy6ut))P06A-(#>*BtJrlJ|T4+w~CR+=*tdb1%mnLOGKBwr*ZA6wwS2yir)q z^o1s6zSg$yWcI;;7Qk|}rMaPNWsgbOZ`u|)%UJNdo_h~BDeMFZe>A#2HmP3O@piL% zco#M1$10>X1IJ8nSb|%?_JWTlU%{30FH`-hTTi_^Y&hcyI}W4@GEjD}uT8w#Mus^v zeKJ!>gz=axmg|Z7>c&{XGM?|VW1jn$cl<-;HgS@&iE(#J+MJ;@YVy>;rU}1hK=vhz zx4-#$<|@;xGA`QcYcs6nd;q)LI>FjYoU+xqruCV8pBE`~sKYo??!!=G=PSBkzxmv=?#b9E#o zI9JDwd07oqxM+zBA3_|`CeOjio10_GCheojhjzq1bpgBxQ*>0BEudNbr>5XW~HQ$F!mu|{FQFZ~AE9fF$!Oso{iN67pv*DI>t+CssGlcfx-zs$ijSK?EYt3Kc5>8ER@`kot+Py);zO=Gs=GGeSpLe zakT8U81TpaP(OO6FvCc1itHRW{Zd`^z<8u`M}d8u%Q0)1A=oM{5{=q#kn{ju2cKb8 zDj~74_;#F1d9Tc36g+=!757Zom_@o8&fOJ0 z1#eUcukG7x0C&th7rSqH0E4&jv%6DAp+F4k42U-^=+B?dmv?@rd6kj=kN3t2(toZT8P{l>Y^VwjAr zjRvq#O^VZI{}v6E1~gQ&#Qiwyzk@G9yhe$qjG7`B2>S)5mfVLvI zI?0s?{~O+_E(LE&n+6sC+bVAeDCEXZktqIeF1&|Mf)a55D(3iK{)7(=Pi@~vw|)QZ tj?jUQ|9Pb33GhFUlsuXLAFa|2|LN=Zt=w)YR#3oy8dx37tMgX_{}(FRsssQ4 literal 0 HcmV?d00001 diff --git a/docs/schema/index.rst b/docs/schema/index.rst new file mode 100644 index 000000000..e5cd48faa --- /dev/null +++ b/docs/schema/index.rst @@ -0,0 +1,1041 @@ +.. _ubcode: https://ubcode.useblocks.com/ +.. _`schema_validation`: + +Schema validation +================= + +This is the documentation of the schema validation implementation as proposed in +the `Github discussion #1451 `__. + +The new schema validation is a versatile, fast, safe and declarative way to enforce constraints +on need items and their relations in Sphinx-Needs. It is supposed to replace the legacy +:ref:`needs_constraints` and :ref:`needs_warnings` configuration options. +See :ref:`migration_from_warnings_constraints` for details on how to migrate. + +.. note:: + + The validation is coercing need extra options only for the schema validation, + so type constraints can be enforced. It does not yet change the Sphinx-Needs internal type + system. The goal of this first implementation is to agree on the interface definition + format and give users time to migrate their warnings/constraints. + A fully typed Sphinx-Needs will come in a second step in the near future and it will affect + dynamic functions, filters and exported/imported needs. + +Imagine the following modeling of need items: + +.. figure:: 01_basic_setup.drawio.png + +There are a few things to note about this setup: + +- the extra options ``efforts``, ``approval`` and + ``asil`` (for **A**\ utomotive **S**\ ecurity **I**\ ntegrity **L**\ evel) are typed +- the assigned extra options differ between need types +- the fields may be optional for a need type, required or even not allowed +- some validation rules are local to the need itself, while others + require information from other needs (network validation) +- the needs link to each other in a specific way, so a + safe ``impl`` can only link to a safe ``spec`` which can only + link to a safe ``feat`` item + +The schema validation in Sphinx-Needs allows you to define declarative rules for validating need +items based on their types, properties and relationships. + +This includes both local checks (validating properties of a single need) and network checks +(validating relationships between multiple needs). The distinction is especially important in +an IDE context, where local checks can provide instant feedback while network requires building +the full network index first. + +Schema Configuration +-------------------- + +Schemas can be configured in two ways: directly in the ``conf.py`` file or loaded from a separate +JSON file. + +**JSON File Configuration (recommended)** + +The preferred approach is to define schemas in a separate JSON file and load them using the +:ref:`needs_schema_definitions_from_json` configuration option: + +.. code-block:: python + + # conf.py + needs_schema_definitions_from_json = "schemas.json" + +Then create a ``schemas.json`` file in your project root: + +.. code-block:: json + + { + "$defs": { + "type-spec": { + "properties": { + "type": { "const": "spec" } + } + }, + }, + "schemas": [ + { + "severity": "warning", + "message": "id must be uppercase", + "select": { + "$ref": "#/$defs/type-spec" + }, + "validate": { + "local": { + "properties": { + "id": { "pattern": "^SPEC_[A-Z0-9_]+$" } + } + } + } + } + ] + } + +**Benefits of JSON File Configuration:** + +- **Declarative**: Schema definitions are separate from Python configuration +- **Version Control**: Easy to track changes to validation rules +- **IDE Support**: `ubCode`_ can read the JSON file + +**Python Configuration (Alternative)** + +Alternatively, schemas can be configured directly using the :ref:`needs_schema_definitions` +configuration option in ``conf.py``: + +.. code-block:: python + + needs_schema_definitions = { + "$defs": { + # reusable schema components + "type-spec": { + "properties": { + "type": { "const": "spec" } + } + }, + }, + "schemas": [ + { + "severity": "warning", + "message": "id must be uppercase", + "select": { + "$ref": "#/$defs/type-spec" + }, + "validate": { + "local": { + "properties": { + "id": { "pattern": "^SPEC_[A-Z0-9_]+$" } + } + } + } + } + ] + } + +.. _`local_validation`: + +Local Validation +---------------- + +Consider the following local checks: + +.. figure:: 02_local_check.drawio.png + +Local validation checks individual need properties without requiring information from other needs: + +- the ``efforts`` field + + - is of type integer + - is optional for ``spec`` and ``feat`` and disallowed for ``impl`` + - has a minimum value of 0 + - has a maximum value of 20 + +- the ``approval`` field + + - is of type boolean + - is optional for ``spec`` and ``feat`` and disallowed for ``impl`` + - is required in case the field ``efforts`` has a value greater than 15; + if the condition is not satisfied, the violation should be returned as ``violation`` + - must be set to ``True`` in case the field ``efforts`` has a value greater than 15; + if the condition is not satisfied, the violation should be returned as ``warning`` + +- the ``asil`` field + + - is of type string + - has a string subtype of ``enum`` + - can only be set to one of the following values: ``QM | A | B | C | D`` + +Example local validation schema: + +.. code-block:: json + + { + "$defs": { + "type-feat": { + "properties": { + "type": { "const": "feat" } + } + }, + "type-spec": { + "properties": { + "type": { "const": "spec" } + } + }, + "type-impl": { + "properties": { + "type": { "const": "impl" } + } + }, + "safe-feat": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-feat" } + ] + }, + "safe-spec": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-spec" } + ] + }, + "safe-impl": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-impl" } + ] + }, + "safe-need": { + "properties": { + "asil": { + "enum": ["A", "B", "C", "D"] + } + }, + "required": ["asil"] + }, + "high-efforts": { + "properties": { + "efforts": { "minimum": 15 } + }, + "required": ["efforts"] + } + }, + "schemas": [ + { + "id": "spec", + "select": { "$ref": "#/$defs/type-spec" }, + "validate": { + "local": { + "properties": { + "id": { "pattern": "^SPEC_[a-zA-Z0-9_-]*$" }, + "efforts": { "minimum": 0 } + }, + "unevaluatedProperties": false + } + } + }, + { + "id": "spec-approved-required", + "severity": "violation", + "message": "Approval required due to high efforts", + "select": { + "allOf": [ + { "$ref": "#/$defs/high-efforts" }, + { "$ref": "#/$defs/type-spec" } + ] + }, + "validate": { + "local": { + "required": ["approved"] + } + } + }, + { + "id": "spec-approved-not-given", + "severity": "info", + "message": "Approval not given", + "select": { + "allOf": [ + { "$ref": "#/$defs/type-spec" }, + { "$ref": "#/$defs/high-efforts" } + ] + }, + "validate": { + "local": { + "properties": { + "approved": { "const": true } + }, + "required": ["approved"] + } + } + } + ] + } + +Above conditions can all be checked locally on need level which allows instant user feedback +in IDE extensions such as `ubCode`_. + +Network Validation +------------------ + +On the other hand, network checks require information from other needs: + +.. figure:: 03_network_check.drawio.png + +After network resolution, the following checks can be performed: + +- a 'safe' ``impl`` that has an ``asil`` of ``A | B | C | D`` cannot ``link`` to ``spec`` items + that have an ``asil`` of ``QM`` +- a safe ``impl`` can only link to 'approved' ``spec`` items with link type ``details`` +- likewise, a safe ``spec`` can only link to safe and approved ``feat`` items +- the safe ``impl`` can link to *one or more* safe ``spec`` items +- a spec can only link to *exactly one* ``feat`` +- additional links to non-validating items are not allowed (that is the min/max constraints are + met but there are failing additional link targets) + +Example network validation schema: + +.. code-block:: json + + { + "$defs": { + "type-feat": { + "properties": { + "type": { "const": "feat" } + } + }, + "type-spec": { + "properties": { + "type": { "const": "spec" } + } + }, + "type-impl": { + "properties": { + "type": { "const": "impl" } + } + }, + "safe-feat": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-feat" } + ] + }, + "safe-spec": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-spec" } + ] + }, + "safe-impl": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-impl" } + ] + }, + "safe-need": { + "properties": { + "asil": { + "enum": ["A", "B", "C", "D"] + } + }, + "required": ["asil"] + }, + "high-efforts": { + "properties": { + "efforts": { "minimum": 15 } + }, + "required": ["efforts"] + } + }, + "schemas": [ + { + "id": "safe-spec-[details]->safe-feat", + "message": "Safe spec details safe and approved feat", + "select": { "$ref": "#/$defs/safe-spec" }, + "validate": { + "network": { + "details": { + "contains": { + "local": { + "properties": { + "approved": { "const": true } + }, + "required": ["approved"], + "allOf": [{ "$ref": "#/$defs/safe-feat" }] + } + }, + "minContains": 1, + "maxContains": 1, + } + } + } + }, + { + "id": "safe-impl-[links]->safe-spec", + "message": "Safe impl links to safe spec", + "select": { "$ref": "#/$defs/safe-impl" }, + "validate": { + "network": { + "links": { + "contains": { + "local": { "$ref": "#/$defs/safe-spec" } + }, + "minContains": 1, + } + } + } + } + ] + } + +Network Link Validation +~~~~~~~~~~~~~~~~~~~~~~~ + +Network validation supports various constraints on linked needs: + +**Link Count Constraints** + +- ``minContains``: Minimum number of valid links required +- ``maxContains``: Maximum number of valid links allowed + +.. code-block:: json + + { + "validate": { + "network": { + "links": { + "minContains": 1, // At least one link required + "maxContains": 3 // Maximum three links allowed + } + } + } + } + +**Link Target Validation** + +The ``items`` property defines validation rules for each linked need: + +.. code-block:: json + + { + "validate": { + "network": { + "links": { + "contains": { + "local": { + "properties": { + "status": { "const": "approved" } + } + } + } + } + } + } + } + +**Nested Network Validation** + +Network validation can be nested to validate multi-hop link chains: + +.. code-block:: json + + { + "id": "safe-impl-chain", + "select": {"$ref": "#/$defs/safe-impl"}, + "validate": { + "network": { + "links": { + "contains": { + "local": {"$ref": "#/$defs/safe-spec"}, + "network": { + "links": { + "contains": { + "local": {"$ref": "#/$defs/safe-feat"} + }, + "minContains": 1 + } + } + }, + "minContains": 1 + } + } + } + } + +This validates that: + +1. A safe implementation links to safe specifications +#. Those specifications in turn link to safe features +#. Both link levels have minimum/maximum count requirements + +Schema Components +----------------- + +Select Criteria +~~~~~~~~~~~~~~~ + +The ``select`` section defines which needs the schema applies to: + +.. code-block:: json + + { + "select": { + "allOf": [ + { "$ref": "#/$defs/type-spec" }, + { "$ref": "#/$defs/high-efforts" } + ] + } + } + +If no ``select`` is provided, the schema applies to all needs. +``select`` is always a local validation, meaning it only checks properties of the need itself. +``select`` validation also means all link fields are list of need ID strings, not need objects. + +Validation Rules +~~~~~~~~~~~~~~~~ + +The ``validate`` section contains the actual validation rules: + +**Local validation** checks individual need properties: + +.. code-block:: json + + { + "validate": { + "local": { + "properties": { + "status": { "enum": ["open", "closed", "in_progress"] } + }, + "required": ["status"] + } + } + } + +``local`` validation also means all link fields are list of need ID strings, not need objects. + +**Unevaluated Properties Control** + +The ``unevaluatedProperties`` property controls whether properties not explicitly defined in the +schema are allowed: + +.. code-block:: json + + { + "validate": { + "local": { + "properties": { + "status": { "enum": ["open", "closed"] } + }, + "unevaluatedProperties": false // Only 'status' property allowed + } + } + } + +When ``unevaluatedProperties: false`` is set and a need has additional properties, +validation will report: + +.. code-block:: text + + Schema message: Unevaluated properties are not allowed ('comment', 'priority' were unexpected) + +This is useful for enforcing strict property schemas and catching typos in property names. +To find out which properties are actually set, the validated needs are reduced to field values +that are not on their default value. + +**unevaluatedProperties with allOf** + +The ``unevaluatedProperties`` validation also works with properties defined in ``allOf`` constructs. +Properties from all schemas in the ``allOf`` array are considered as evaluated: + +.. code-block:: json + + { + "validate": { + "local": { + "properties": { "asil": {} }, + "unevaluatedProperties": false, + "allOf": [ + { "properties": { "comment": {} } } + ] + } + } + } + +In this example, both ``asil`` and ``comment`` properties are considered evaluated, so only these +two properties would be allowed on the need. Empty schemas for a field are allowed to mark +them as evaluated. The behavior is aligned with the JSON Schema specification. + +**required vs unevaluatedProperties** + +The ``required`` list has no impact on ``unevaluatedProperties`` validation. +Properties listed in ``required`` must still be explicitly defined in ``properties`` or pulled +in via ``allOf`` to be considered evaluated: + +.. code-block:: json + + { + "validate": { + "local": { + "properties": { "status": {} }, + "required": ["status", "priority"], // priority not in properties + "unevaluatedProperties": false + } + } + } + +In this case, a need with a ``priority`` property would still trigger an unevaluated properties +error, even though ``priority`` is in the ``required`` list. + +Severity Levels +~~~~~~~~~~~~~~~ + +Each schema can specify a severity level: + +- ``violation`` (default): Violation message +- ``warning``: Warning message +- ``info``: Informational message + +.. code-block:: json + + { + "severity": "warning", + "message": "Approval required due to high efforts" + } + +The config :ref:`needs_schema_severity` can be used to define a minimum severity level for a +warning to be reported. + +Schema Definitions ($defs) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Reusable schema components can be defined in the ``$defs`` section: + +.. code-block:: json + + { + "$defs": { + "type-feat": { + "properties": { + "type": { "const": "feat" } + } + }, + "safe-need": { + "properties": { + "asil": { "enum": ["A", "B", "C", "D"] } + }, + "required": ["asil"] + }, + "safe-feat": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-feat" } + ] + } + } + } + +A full example is outlined in the :ref:`local_validation` section. + +Error Messages +-------------- + +Validation errors include detailed information: + +- **Severity**: The severity level of the violation +- **Field**: The specific field that failed validation +- **Need path**: The ID of the need that failed or the link chain for network validation +- **Schema path**: The JSON path within the schema that was violated +- **User message**: Custom message from the needs_schema.schemas list +- **Schema message**: Detailed technical validation message from the validator + +Example error output:: + + Need 'SPEC_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P01' does not match '^REQ[a-zA-Z0-9_-]*$' + +For nested network validation, it can be difficult to determine which constraint and need +caused the error in the chain. In such cases, the error will emit details about the failed +need and the specific link that caused the issue:: + + WARNING: Need 'IMPL_SAFE' has validation errors: + Severity: violation + Need path: IMPL_SAFE > links + Schema path: safe-impl-[links]->safe-spec-[links]->safe-req[0] > validate > network > links + User message: Safe impl links to safe spec links to safe req + Schema message: Too few valid links of type 'links' (0 < 1) / nok: SPEC_SAFE + + Details for SPEC_SAFE + Need path: IMPL_SAFE > links > SPEC_SAFE > links + Schema path: safe-impl-[links]->safe-spec-[links]->safe-req[0] > links > validate > network > links + Schema message: Too few valid links of type 'links' (0 < 1) / nok: REQ_UNSAFE + + Details for REQ_UNSAFE + Field: asil + Need path: IMPL_SAFE > links > SPEC_SAFE > links > REQ_UNSAFE + Schema path: safe-impl-[links]->safe-spec-[links]->safe-req[0] > links > links > local > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_contains_too_few] + +Supported Data Types +-------------------- + +Sphinx-Needs supports comprehensive data type validation for need options through JSON Schema. +The following data types are available for need options: + +String Type +~~~~~~~~~~~ + +The default data type for need options. Supports various format validations: + +.. code-block:: json + + { + "properties": { + "description": { + "type": "string", + "minLength": 10, + "maxLength": 500 + } + } + } + +**String Formats** + +String fields can be validated against specific formats using the ``format`` property: + +**Date and Time Formats (ISO 8601)** + +.. code-block:: json + + { + "properties": { + "start_date": {"type": "string", "format": "date"}, // 2023-12-25 + "created_at": {"type": "string", "format": "date-time"}, // 2023-12-25T14:30:00Z + "meeting_time": {"type": "string", "format": "time"}, // 14:30:00 + "project_duration": {"type": "string", "format": "duration"} // P1Y2M10DT2H30M + } + } + +**Communication Formats** + +.. code-block:: json + + { + "properties": { + "contact_email": {"type": "string", "format": "email"}, // user@example.com (RFC 5322) + "project_url": {"type": "string", "format": "uri"}, // https://example.com (RFC 3986) + "tracking_id": {"type": "string", "format": "uuid"} // 123e4567-e89b-12d3-a456-426614174000 (RFC 4122) + } + } + +**Enumerated Values** + +.. code-block:: json + + { + "properties": { + "priority": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + } + } + } + +Integer Type +~~~~~~~~~~~~ + +Whole number validation with range constraints: + +.. code-block:: json + + { + "properties": { + "efforts": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "multipleOf": 5 + } + } + } + +**Note**: Values are stored as strings in Sphinx-Needs but validated as integers during +schema validation. + +Number Type +~~~~~~~~~~~ + +Floating-point number validation: + +.. code-block:: json + + { + "properties": { + "cost_estimate": { + "type": "number", + "minimum": 0.0, + "exclusiveMaximum": 1000000.0 + } + } + } + +**Note**: Values are stored as strings in Sphinx-Needs but validated as numbers during +schema validation. + +Boolean Type +~~~~~~~~~~~~ + +Boolean validation with flexible input handling: + +.. code-block:: json + + { + "properties": { + "approved": {"type": "boolean"}, + "is_critical": {"type": "boolean", "const": true} + } + } + +**Accepted Boolean Values**: + +- **Truthy**: ``true``, ``yes``, ``y``, ``on``, ``1``, ``True``, ``Yes``, ``On`` +- **Falsy**: ``false``, ``no``, ``n``, ``off``, ``0``, ``False``, ``No``, ``Off`` + +The ``enum`` keyword cannot be used for booleans as ``const`` is functionally equivalent and +more expressive. + +Array Type +~~~~~~~~~~ + +Multi-value options supporting arrays of the above basic types: + +.. code-block:: json + + { + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 10, + "splitChar": "," + } + } + } + +**Array Properties**: + +- ``items``: Schema for individual array elements +- ``minItems`` / ``maxItems``: Array size constraints +- ``splitChar``: Character used to split string input (default: ``,``) + +.. note:: + + This ``array`` type with ``splitChar`` does not yet work for extra options. This is + planned for a future release. + +Regex Pattern Restrictions +-------------------------- + +When using ``pattern`` for string types in schemas, the regex patterns must be compatible +across multiple language engines such as Python, Rust, and SQLite to consume the patterns +also in the bigger Sphinx-Needs ecosystem. +The following constructs are **not allowed**: + +**Prohibited Constructs:** + +- **Lookaheads/Lookbehinds**: ``(?=pattern)``, ``(?!pattern)``, ``(?<=pattern)``, ``(?pattern)`` (not supported in all engines) +- **Recursive Patterns**: ``(?R)`` (not supported in all engines) + +**Safe Patterns:** + +.. code-block:: json + + { + "properties": { + "id": { "pattern": "^[A-Z0-9_]+$" }, // ✓ Safe + "version": { "pattern": "^v[0-9]+\\.[0-9]+$" }, // ✓ Safe + "status": { "pattern": "^(open|closed)$" } // ✓ Safe + } + } + +**Unsafe Patterns:** + +.. code-block:: json + + { + "properties": { + "id": { "pattern": "^(?=.*[A-Z]).*$" }, // ✗ Lookahead + "ref": { "pattern": "^(\\w+)_\\1$" }, // ✗ Backreference + "complex": { "pattern": "^(a+)+$" } // ✗ Nested quantifiers + } + } + +The validation will reject schemas containing unsafe patterns and provide +clear error messages indicating the specific issue. Some constructs might be +restricted in future versions of Sphinx-Needs if they cannot be safely evaluated +in all relevant engines. + +Best Practices +-------------- + +1. **Use descriptive IDs**: Give your schemas meaningful IDs for easier debugging +#. **Leverage $defs**: Define reusable schema components to avoid duplication +#. **Start with warnings**: Use ``warning`` severity during development, then upgrade to ``violation`` +#. **Provide clear messages**: Include helpful ``message`` fields to guide users +#. **Test incrementally**: Add schemas gradually to avoid overwhelming validation errors +#. **Use select wisely**: Only apply schemas to relevant need types using ``select`` + +.. _`migration_from_warnings_constraints`: + +Migration from Legacy Validation +-------------------------------- + +The schema validation system is designed to replace the older :ref:`needs_constraints` and +:ref:`needs_warnings` configuration options, offering significant advantages: + +- **Declarative**: JSON-based configuration instead of Python code +- **Powerful**: Supports selection, local, and network validation +- **Performance**: Schema validation is faster than custom validations written in Python +- **IDE Support**: Full IntelliSense and validation in supported editors like `ubCode`_ +- **Type Safety**: Strong typing with comprehensive data type support +- **Network Validation**: Multi-hop link validation capabilities +- **Maintainability**: Easier to read, write, and version control + +**Migration Examples** + +**From needs_constraints:** + +.. code-block:: python + + # Old approach - needs_constraints + needs_constraints = { + "security": { + "check_0": "'security' in tags", + "severity": "CRITICAL" + }, + "critical": { + "check_0": "'critical' in tags", + "severity": "CRITICAL", + "error_message": "need {{id}} does not fulfill CRITICAL constraint" + } + } + +.. code-block:: json + + { + "schemas": [ + { + "id": "security-constraint", + "severity": "violation", + "message": "Security needs must have security tag", + "select": { + "properties": { + "tags": { + "type": "array", + "contains": {"const": "security"} + } + } + }, + "validate": { + "local": { + "properties": { + "tags": { + "type": "array", + "contains": {"const": "security"} + } + } + } + } + } + ] + } + +**From needs_warnings:** + +.. code-block:: python + + # Old approach - needs_warnings + def my_custom_warning_check(need, log): + if need["status"] not in ["open", "closed", "done"]: + return True + return False + + needs_warnings = { + "invalid_status": "status not in ['open', 'closed', 'done']", + "type_match": my_custom_warning_check + } + +.. code-block:: json + + { + "schemas": [ + { + "id": "valid-status", + "severity": "warning", + "message": "Status must be one of the allowed values", + "validate": { + "local": { + "properties": { + "status": { + "enum": ["open", "closed", "done"] + } + }, + "required": ["status"] + } + } + } + ] + } + +**Network Validation Benefits** + +The schema system provides capabilities not available in the legacy systems: + +.. code-block:: json + + { + "schemas": [ + { + "id": "safe-implementation-links", + "message": "Safe implementations must link to approved specifications", + "select": { + "allOf": [ + {"$ref": "#/$defs/type-impl"}, + {"$ref": "#/$defs/safety-critical"} + ] + }, + "validate": { + "network": { + "links": { + "contains": { + "local": { + "allOf": [ + {"$ref": "#/$defs/type-spec"}, + {"properties": {"approved": {"const": true}}} + ] + } + }, + "minContains": 1 + } + } + } + } + ] + } + +This type of multi-need relationship validation was not possible with the legacy constraint +and warning systems. + +**Recommended Migration Path** + +1. **Audit existing constraints and warnings**: Review your current validation rules +2. **Start with local validations**: Convert simple property checks first +3. **Leverage network validation**: Replace complex Python logic with declarative schemas +4. **Test incrementally**: Validate schemas work as expected before removing legacy rules +5. **Update documentation**: Ensure team members understand the new validation approach diff --git a/pyproject.toml b/pyproject.toml index 0dbd259d8..f30d6c33e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,10 +30,12 @@ dependencies = [ "sphinx>=7.4,<9", "requests-file~=2.1", # external links "requests~=2.32", # external links - "jsonschema>=3.2.0", # needsimport schema validation + "jsonschema[format]>=3.2.0", # schema validation for needsimport and ontology "sphinx-data-viewer~=0.1.5", # needservice debug output "sphinxcontrib-jquery~=4.0", # needed for datatables in sphinx>=6 "tomli; python_version < '3.11'", # for needs_from_toml configuration + "typing-extensions>=4.14.0", # for dict NotRequired type indication + "typeguard>=4.4.4", # for type checking complex schema configs ] [project.optional-dependencies] @@ -48,6 +50,7 @@ test = [ "lxml>=4.6.5,<6.0", "responses~=0.22.0", "pytest-xprocess~=1.0", + "pyyaml ~= 6.0", ] test-parallel = ["pytest-xdist"] benchmark = [ @@ -72,6 +75,7 @@ dev = ["pre-commit~=3.0", "tox~=4.23", "tox-uv~=1.15"] [tool.pytest.ini_options] markers = [ "jstest: marks tests as JavaScript test (deselect with '-m \"not jstest\"')", + "benchmark: marks tests as expensive benchmark test (deselect with '-m \"not benchmark\"')", ] filterwarnings = [ "ignore:.*removed in Python 3.14.*:DeprecationWarning", diff --git a/sphinx_needs/builder.py b/sphinx_needs/builder.py index 6c612cf90..c35ad16e6 100644 --- a/sphinx_needs/builder.py +++ b/sphinx_needs/builder.py @@ -41,7 +41,13 @@ def write( updated_docnames: Sequence[str], method: str = "update", ) -> None: - return + # we override this method, to stop any document output files from being written, + # however, from this method triggers the `write-started` event, + # which we still want for triggering schema validation. + # TODO since sphinx 8.1 `Builder.write` is typed as `final` and a new `Builder.write_documents` method is added, + # see https://github.com/sphinx-doc/sphinx/commit/d135d2eba39136941da101e7933a958362dfa999 + # once sphinx 7 is not supported, we should remove this `write` method and override `write_documents` to "do nothing" + self.events.emit("write-started", self) def finish(self) -> None: from sphinx_needs.filter_common import filter_needs_view @@ -257,3 +263,39 @@ def build_needumls_pumls(app: Sphinx, _exception: Exception) -> None: needs_builder.outdir = os.path.join(needs_builder.outdir, config.build_needumls) # type: ignore[assignment] needs_builder.finish() + + +class SchemaBuilder(Builder): + """Only validate needs schema, no output is generated.""" + + name = "schema" + + def write( + self, + build_docnames: Iterable[str] | None, + updated_docnames: Sequence[str], + method: str = "update", + ) -> None: + # make sure schema validation is done + self.events.emit("write-started", self) + + def write_doc(self, docname: str, doctree: nodes.document) -> None: + pass + + def finish(self) -> None: + pass + + def get_outdated_docs(self) -> Iterable[str]: + return [] + + def prepare_writing(self, _docnames: set[str]) -> None: + pass + + def write_doc_serialized(self, _docname: str, _doctree: nodes.document) -> None: + pass + + def cleanup(self) -> None: + pass + + def get_target_uri(self, _docname: str, _typ: str | None = None) -> str: + return "" diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index 3c245609c..28be45c41 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Mapping from dataclasses import MISSING, dataclass, field, fields -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from docutils.parsers.rst import directives from sphinx.application import Sphinx @@ -11,6 +11,17 @@ from sphinx_needs.data import GraphvizStyleType, NeedsCoreFields from sphinx_needs.defaults import DEFAULT_DIAGRAM_TEMPLATE from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.schema.config import ( + ExtraLinkSchemaType, + ExtraOptionBooleanSchemaType, + ExtraOptionIntegerSchemaType, + ExtraOptionMultiValueSchemaType, + ExtraOptionNumberSchemaType, + ExtraOptionSchemaTypes, + ExtraOptionStringSchemaType, + SchemasFileRootType, + SeverityEnum, +) if TYPE_CHECKING: from sphinx.util.logging import SphinxLoggerAdapter @@ -31,6 +42,15 @@ class ExtraOptionParams: """A description of the option.""" validator: Callable[[str | None], str] """A function to validate the directive option value.""" + schema: ( + ExtraOptionStringSchemaType + | ExtraOptionBooleanSchemaType + | ExtraOptionIntegerSchemaType + | ExtraOptionNumberSchemaType + | ExtraOptionMultiValueSchemaType + | None + ) + """A JSON schema for the option.""" class FieldDefault(TypedDict): @@ -91,6 +111,12 @@ def add_extra_option( name: str, description: str, *, + schema: ExtraOptionStringSchemaType + | ExtraOptionBooleanSchemaType + | ExtraOptionIntegerSchemaType + | ExtraOptionNumberSchemaType + | ExtraOptionMultiValueSchemaType + | None = None, validator: Callable[[str | None], str] | None = None, override: bool = False, ) -> None: @@ -110,7 +136,9 @@ def add_extra_option( raise NeedsApiConfigWarning(f"Option {name} already registered.") self._extra_options[name] = ExtraOptionParams( - description, directives.unchanged if validator is None else validator + description, + directives.unchanged if validator is None else validator, + schema, ) @property @@ -240,6 +268,14 @@ class LinkOptionsType(TypedDict, total=False): """Used for needflow. Default: '->'""" allow_dead_links: bool """If True, add a 'forbidden' class to dead links""" + schema: NotRequired[ExtraLinkSchemaType] + """ + A JSON schema for the link option. + + If given, the schema will apply to all needs that use this link option. + The schema is applied locally on unresolved links, i.e. on the list of string ids. + For more granular control and graph traversal, use the `needs_schema_definitions` configuration. + """ class NeedType(TypedDict): @@ -263,6 +299,13 @@ class NeedExtraOption(TypedDict): name: str description: NotRequired[str] """A description of the option.""" + schema: NotRequired[ExtraOptionSchemaTypes] + """ + A JSON schema definition for the option. + + If given, the schema will apply to all needs that use this option. + For more granular control, use the `needs_schema_definitions` configuration. + """ class NeedStatusesOption(TypedDict): @@ -366,6 +409,57 @@ def get_default(cls, name: str) -> Any: ) """Path to the root table in the toml file to load configuration from.""" + schema_definitions: SchemasFileRootType = field( + default_factory=lambda: cast(SchemasFileRootType, {}), + metadata={"rebuild": "env", "types": (dict,)}, + ) + """Schema definitions to write complex valdations based on selectors.""" + + schema_definitions_from_json: str | None = field( + default=None, metadata={"rebuild": "env", "types": (str, type(None))} + ) + """Path to a JSON file to load the schemas from.""" + + schema_severity: str = field( + default=SeverityEnum.info.name, + metadata={"rebuild": "env", "types": (str,)}, + ) + """Severity level for the schema validation reporting.""" + + schema_debug_active: bool = field( + default=False, + metadata={"rebuild": "env", "types": (bool,)}, + ) + """Activate the debug mode for schema validation to dump JSON/schema files and messages.""" + + schema_debug_path: str = field( + default="schema_debug", + metadata={"rebuild": "env", "types": (str,)}, + ) + """ + Path to the directory where the debug files are stored. + + If the path is relative, the caller needs to make sure + it gets converted to a use case specific absolute path, e.g. + with confdir for Sphinx. + """ + + schema_debug_ignore: list[str] = field( + default_factory=lambda: [ + "extra_option_success", + "extra_link_success", + "select_success", + "select_fail", + "local_success", + "network_local_success", + ], + metadata={ + "rebuild": "env", + "types": (list,), + }, + ) + """List of scenarios that are ignored for dumping debug information.""" + types: list[NeedType] = field( default_factory=lambda: [ { diff --git a/sphinx_needs/environment.py b/sphinx_needs/environment.py index aae33af5d..79a9a54b5 100644 --- a/sphinx_needs/environment.py +++ b/sphinx_needs/environment.py @@ -40,7 +40,7 @@ def _add_js_file(app: Sphinx, rel_path: Path) -> None: def install_styles_static_files(app: Sphinx, env: BuildEnvironment) -> None: builder = app.builder # Do not copy static_files for our "needs" builder - if builder.name == "needs": + if builder.name in ["needs", "schema"]: return logger.info("Copying static style files for sphinx-needs") @@ -87,7 +87,7 @@ def install_lib_static_files(app: Sphinx, env: BuildEnvironment) -> None: """ builder = app.builder # Do not copy static_files for our "needs" builder - if builder.name == "needs": + if builder.name in ["needs", "schema"]: return logger.info("Copying static files for sphinx-needs datatables support") @@ -116,7 +116,7 @@ def install_permalink_file(app: Sphinx, env: BuildEnvironment) -> None: """ builder = app.builder # Do not copy static_files for our "needs" builder - if builder.name == "needs": + if builder.name in ["needs", "schema"]: return # load jinja template diff --git a/sphinx_needs/exceptions.py b/sphinx_needs/exceptions.py index 0174a76a0..93dd8382d 100644 --- a/sphinx_needs/exceptions.py +++ b/sphinx_needs/exceptions.py @@ -74,3 +74,7 @@ class NeedsConstraintFailed(SphinxError): class NeedsInvalidFilter(SphinxError): pass + + +class NeedsConfigException(SphinxError): + pass diff --git a/sphinx_needs/logging.py b/sphinx_needs/logging.py index e5a88f2e2..f7e39e278 100644 --- a/sphinx_needs/logging.py +++ b/sphinx_needs/logging.py @@ -12,7 +12,9 @@ def get_logger(name: str) -> SphinxLoggerAdapter: return logging.getLogger(name) +# keep below 2 dicts sorted to spot missing items WarningSubTypes = Literal[ + "beta", "config", "constraint", "create_need", @@ -23,15 +25,8 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "duplicate_part_id", "dynamic_function", "external_link_outgoing", - "needextend", - "needextract", - "needflow", - "needgantt", - "needimport", - "needreport", - "needsequence", - "filter", "filter_func", + "filter", "github", "import_need", "json_load", @@ -41,19 +36,27 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "link_text", "load_external_need", "load_service_need", + "mistyped_external_values", + "mistyped_import_values", "mpl", + "needextend", + "needextract", + "needflow", + "needgantt", + "needimport", + "needreport", + "needsequence", "part", "title", "uml", "unknown_external_keys", - "mistyped_external_values", "unknown_import_keys", - "mistyped_import_values", "variant", "warnings", ] WarningSubTypeDescription: dict[WarningSubTypes, str] = { + "beta": "Beta feature, subject to change", "config": "Invalid configuration", "constraint": "Constraint violation", "create_need": "Creation of a need from directive failed", @@ -63,32 +66,32 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "duplicate_id": "Duplicate need ID found when merging needs from parallel processes", "duplicate_part_id": "Duplicate part ID found when parsing need content", "dynamic_function": "Failed to load/execute dynamic function", - "needextend": "Error processing needextend directive", - "needextract": "Error processing needextract directive", - "needflow": "Error processing needflow directive", - "needgantt": "Error processing needgantt directive", - "needimport": "Error processing needimport directive", - "needreport": "Error processing needreport directive", - "needsequence": "Error processing needsequence directive", - "filter": "Error processing needs filter", + "external_link_outgoing": "Unknown outgoing link in external need", "filter_func": "Error loading needs filter function", + "filter": "Error processing needs filter", "github": "Error in processing GitHub service directive", "import_need": "Failed to import a need", "layout": "Error occurred during layout rendering of a need", "link_outgoing": "Unknown outgoing link in standard need", - "external_link_outgoing": "Unknown outgoing link in external need", "link_ref": "Need could not be referenced", "link_text": "Reference text could not be generated", "load_external_need": "Failed to load an external need", "load_service_need": "Failed to load a service need", + "mistyped_external_values": "Unexpected value types found in external need data", + "mistyped_import_values": "Unexpected value types found in imported need data", "mpl": "Matplotlib required but not installed", + "needextend": "Error processing needextend directive", + "needextract": "Error processing needextract directive", + "needflow": "Error processing needflow directive", + "needgantt": "Error processing needgantt directive", + "needimport": "Error processing needimport directive", + "needreport": "Error processing needreport directive", + "needsequence": "Error processing needsequence directive", "part": "Error processing need part", "title": "Error creating need title", "uml": "Error in processing of UML diagram", "unknown_external_keys": "Unknown keys found in external need data", - "mistyped_external_values": "Unexpected value types found in external need data", "unknown_import_keys": "Unknown keys found in imported need data", - "mistyped_import_values": "Unexpected value types found in imported need data", "variant": "Error processing variant in need field", "warnings": "Need warning check failed for one or more needs", } diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index 67267a7f5..855464cbf 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -1,18 +1,19 @@ from __future__ import annotations import contextlib +import json from collections.abc import Callable from pathlib import Path from timeit import default_timer as timer # Used for timing measurements -from typing import Any, Literal +from typing import Any, Literal, cast from docutils import nodes from docutils.parsers.rst import directives from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.config import Config +from sphinx.config import Config as _SphinxConfig from sphinx.environment import BuildEnvironment -from sphinx.errors import SphinxError import sphinx_needs.debug as debug # Need to set global var in it for timeing measurements from sphinx_needs import __version__ @@ -22,6 +23,7 @@ NeedsBuilder, NeedsIdBuilder, NeedumlsBuilder, + SchemaBuilder, build_needs_id_json, build_needs_json, build_needumls_pumls, @@ -105,6 +107,7 @@ install_permalink_file, install_styles_static_files, ) +from sphinx_needs.exceptions import NeedsConfigException from sphinx_needs.external_needs import load_external_needs from sphinx_needs.functions import NEEDS_COMMON_FUNCTIONS from sphinx_needs.logging import get_logger, log_warning @@ -116,6 +119,9 @@ from sphinx_needs.roles.need_outgoing import NeedOutgoing, process_need_outgoing from sphinx_needs.roles.need_part import NeedPart, NeedPartRole, process_need_part from sphinx_needs.roles.need_ref import NeedRef, process_need_ref +from sphinx_needs.schema.config import SchemasFileRootType +from sphinx_needs.schema.config_utils import validate_schemas_config +from sphinx_needs.schema.process import process_schemas from sphinx_needs.services.github import GithubService from sphinx_needs.services.open_needs import OpenNeedsService from sphinx_needs.utils import node_match @@ -126,6 +132,7 @@ except ImportError: import tomli as tomllib + VERSION = __version__ _NODE_TYPES_T = dict[ @@ -159,6 +166,33 @@ LOGGER = get_logger(__name__) +def load_schemas_config_from_json(app: Sphinx, config: _SphinxConfig) -> None: + """Merge the configuration from the JSON file into the Sphinx config.""" + needs_config = NeedsSphinxConfig(config) + if needs_config.schema_definitions_from_json is None: + return + if needs_config.schema_definitions: + raise NeedsConfigException( + "You cannot use both 'needs_schema_definitions' and 'needs_schema_definitions_from_json' at the same time." + ) + json_file = Path(app.confdir, needs_config.schema_definitions_from_json).resolve() + + if not json_file.exists(): + raise NeedsConfigException( + f"'sn_schema_from_json' file does not exist: {json_file}" + ) + + try: + with Path(json_file).open("rb") as fp: + json_data = json.load(fp) + assert isinstance(json_data, dict), "Data must be a dict" + except Exception as exc: + raise NeedsConfigException(f"Could not load JSON file: {exc}") from exc + + # schema_definitions are checked later in validate_schemas_config() + needs_config.schema_definitions = cast(SchemasFileRootType, json_data) + + def setup(app: Sphinx) -> dict[str, Any]: LOGGER.debug("Starting setup of Sphinx-Needs") LOGGER.debug("Load Sphinx-Data-Viewer for Sphinx-Needs") @@ -169,6 +203,7 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_builder(NeedsBuilder) app.add_builder(NeedumlsBuilder) app.add_builder(NeedsIdBuilder) + app.add_builder(SchemaBuilder) NeedsSphinxConfig.add_config_values(app) @@ -297,6 +332,7 @@ def setup(app: Sphinx) -> dict[str, Any]: app.connect("doctree-resolved", process_need_nodes) app.connect("doctree-resolved", process_creator(NODE_TYPES)) + app.connect("write-started", process_schemas) app.connect("write-started", ensure_post_process_needs_data) app.connect("build-finished", process_warnings) @@ -376,6 +412,9 @@ def process_caller(app: Sphinx, doctree: nodes.document, fromdocname: str) -> No def load_config_from_toml(app: Sphinx, config: Config) -> None: """ Load config from toml file, if defined in conf.py + + All configs starting with "schema_" are loaded from a dedicated + "schema" table in the toml file. """ needs_config = NeedsSphinxConfig(config) if needs_config.from_toml is None: @@ -399,6 +438,11 @@ def load_config_from_toml(app: Sphinx, config: Config) -> None: for key in (*toml_path, "needs"): toml_data = toml_data[key] assert isinstance(toml_data, dict), "Data must be a dict" + if "schema" in toml_data: + assert isinstance(toml_data["schema"], dict), ( + "'schema' table must be a dict" + ) + except Exception as e: log_warning( LOGGER, @@ -409,11 +453,17 @@ def load_config_from_toml(app: Sphinx, config: Config) -> None: return allowed_keys = NeedsSphinxConfig.field_names() + for key, value in toml_data.items(): if key not in allowed_keys: continue config["needs_" + key] = value + for key, value in toml_data.get("schema", {}).items(): + if key not in allowed_keys: + continue + config["needs_schema_"][key] = value + def load_config(app: Sphinx, *_args: Any) -> None: """ @@ -432,6 +482,7 @@ def load_config(app: Sphinx, *_args: Any) -> None: for option in needs_config._extra_options: description = "Added by needs_extra_options config" + schema = None if isinstance(option, str): name = option elif isinstance(option, dict): @@ -446,6 +497,7 @@ def load_config(app: Sphinx, *_args: Any) -> None: ) continue description = option.get("description", description) + schema = option.get("schema") else: log_warning( LOGGER, @@ -455,7 +507,7 @@ def load_config(app: Sphinx, *_args: Any) -> None: ) continue - _NEEDS_CONFIG.add_extra_option(name, description, override=True) + _NEEDS_CONFIG.add_extra_option(name, description, schema=schema, override=True) # ensure options for ``needgantt`` functionality are added to the extra options for option in (needs_config.duration_option, needs_config.completion_option): @@ -566,6 +618,8 @@ def load_config(app: Sphinx, *_args: Any) -> None: None, ) + load_schemas_config_from_json(app, app.config) + def visitor_dummy(*_args: Any, **_kwargs: Any) -> None: """ @@ -745,6 +799,8 @@ def check_configuration(app: Sphinx, config: Config) -> None: _gather_field_defaults(needs_config, set(link_types)) + validate_schemas_config(needs_config) + def _gather_field_defaults( needs_config: NeedsSphinxConfig, link_types: set[str] @@ -927,10 +983,6 @@ def _check_type( return True -class NeedsConfigException(SphinxError): - pass - - def release_data_locks(app: Sphinx, _exception: Exception) -> None: """Release the lock on needs data mutations. diff --git a/sphinx_needs/schema/__init__.py b/sphinx_needs/schema/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sphinx_needs/schema/config.py b/sphinx_needs/schema/config.py new file mode 100644 index 000000000..d909c7a7b --- /dev/null +++ b/sphinx_needs/schema/config.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +from enum import Enum, IntEnum +from typing import Final, Literal, TypedDict + +from typing_extensions import NotRequired + +EXTRA_OPTION_BASE_TYPES_STR: Final[set[str]] = { + "string", + "integer", + "number", + "boolean", + "array", +} +"""Extra option base types as string that are supported in the schemas.""" + +EXTRA_OPTION_BASE_TYPES_TYPE = Literal[ + "string", "integer", "number", "boolean", "array" +] +"""Extra option base types as types that are supported in the schemas.""" + +MAX_NESTED_NETWORK_VALIDATION_LEVELS: Final[int] = 4 +"""Maximum number of nested network validation levels.""" + + +class ExtraOptionStringSchemaType(TypedDict): + # string is the default type and injected automatically + type: NotRequired[Literal["string"]] + """Extra option string type.""" + minLength: NotRequired[int] + """Minimum length of the string.""" + maxLength: NotRequired[int] + """Maximum length of the string.""" + pattern: NotRequired[str] + """A regex pattern to validate against.""" + format: NotRequired[ + Literal[ + "date-time", + "date", + "time", + "duration", + "email", + "uri", + "uuid", + ] + ] + """A format string to validate against, e.g. 'date-time'.""" + enum: NotRequired[list[str]] + """A list of allowed values for the string.""" + const: NotRequired[str] + """A constant value that the string must match.""" + default: NotRequired[str] + """Default value used in IDE scenarios for autocompletion.""" + + +class ExtraOptionNumberSchemaType(TypedDict): + """ + Float extra option schema. + + The option will still be stored as a string in Sphinx-Needs, + but during schema validation, the value will be coerced to the given type. + + Python reads in JSON numbers as floats. The jsonschema library + handles precision issues with floats using a tolerance-based approach. + """ + + type: Literal["number"] + """Extra option integer type.""" + minimum: NotRequired[float] + """Minimum value.""" + exclusiveMinimum: NotRequired[float] + """Exclusive minimum value.""" + maximum: NotRequired[float] + """Maximum value.""" + exclusiveMaximum: NotRequired[float] + """Exclusive maximum value.""" + multipleOf: NotRequired[float] + """Restriction to multiple of a given number, must be positive.""" + enum: NotRequired[list[float]] + """A list of allowed values.""" + const: NotRequired[float] + """A constant value that the number must match.""" + default: NotRequired[float] + """Default value used in IDE scenarios for autocompletion.""" + + +class ExtraOptionIntegerSchemaType(TypedDict): + """ + Integer extra option schema. + + The option will still be stored as a string in Sphinx-Needs, + but during schema validation, the value will be coerced to the given type. + """ + + type: Literal["integer"] + """Extra option integer type.""" + minimum: NotRequired[int] + """Minimum value.""" + exclusiveMinimum: NotRequired[int] + """Exclusive minimum value.""" + maximum: NotRequired[int] + """Maximum value.""" + exclusiveMaximum: NotRequired[int] + """Exclusive maximum value.""" + multipleOf: NotRequired[int] + """Restriction to multiple of a given integer, must be positive.""" + enum: NotRequired[list[int]] + """A list of allowed values.""" + const: NotRequired[int] + """A constant value that the integer must match.""" + default: NotRequired[int] + """Default value used in IDE scenarios for autocompletion.""" + + +class ExtraOptionBooleanSchemaType(TypedDict): + """ + Boolean extra option schema. + + A predefined set of truthy/falsy strings are accepted: + - truthy = {"true", "yes", "y", "on", "1"} + - falsy = {"false", "no", "n", "off", "0"} + + For fixed values, the const field can be used. + enum is not supported as const is functionally equivalent and less verbose. + """ + + type: Literal["boolean"] + """Extra option boolean type.""" + const: NotRequired[bool] + """A constant value that the integer must match.""" + default: NotRequired[bool] + """Default value used in IDE scenarios for autocompletion.""" + + +RefItemType = TypedDict( + "RefItemType", + { + "$ref": str, + }, +) + +SchemaVersionType = TypedDict( + "SchemaVersionType", + { + "$schema": NotRequired[str], + }, +) + + +class AllOfSchemaType(TypedDict): + allOf: NotRequired[list[RefItemType | NeedFieldsSchemaType]] + + +ExtraOptionBaseSchemaTypes = ( + ExtraOptionStringSchemaType + | ExtraOptionBooleanSchemaType + | ExtraOptionIntegerSchemaType + | ExtraOptionNumberSchemaType +) +"""Union type for all single value extra option schemas.""" + + +class ExtraOptionMultiValueSchemaType(TypedDict): + """ + Multi value extra option such as a list of strings, integers, numbers, or booleans. + + Current SN use case are tags. + """ + + type: NotRequired[Literal["array"]] + """Multi value extra option such as a list of strings, integers, numbers, or booleans.""" + items: NotRequired[ExtraOptionBaseSchemaTypes] + """Schema constraints for link strings.""" + minItems: NotRequired[int] + """Minimum number of items in the array.""" + maxItems: NotRequired[int] + """Maximum number of items in the array.""" + splitChar: NotRequired[str] + """Split character for the array items, defaults to ','.""" + default: NotRequired[list[str] | list[int] | list[float] | list[bool]] + """Default value used in IDE scenarios for autocompletion.""" + + +ExtraOptionSchemaTypes = ExtraOptionBaseSchemaTypes | ExtraOptionMultiValueSchemaType +"""Union type for all extra option schemas, including multi-value options.""" + + +class ExtraLinkItemSchemaType(TypedDict): + """Items in array of link string ids""" + + # links are always string and injected automatically + type: NotRequired[Literal["string"]] + """Extra link string type, can only be string and is injected automatically.""" + minLength: NotRequired[int] + """Minimum string length of each outgoing link id.""" + maxLength: NotRequired[int] + """Maximum string length of each outgoing link id.""" + pattern: NotRequired[str] + """A regex pattern to validate against.""" + + +class ExtraLinkSchemaType(TypedDict): + """Defines a schema for unresolved need extra string links.""" + + type: NotRequired[Literal["array"]] + """Type for extra links, can only be array and is injected automatically.""" + items: NotRequired[ExtraLinkItemSchemaType] + """Schema constraints that applies to all items in the need id string list.""" + minItems: NotRequired[int] + """Minimum number of items in the array (outgoing link ids).""" + maxItems: NotRequired[int] + """Maximum number of items in the array (outgoing link ids).""" + contains: NotRequired[ExtraLinkItemSchemaType] + """Schema constraints that must be contained in the need id string list.""" + minContains: NotRequired[int] + """Minimum number of contains items in the array (outgoing link ids).""" + maxContains: NotRequired[int] + """Maximum number of contains items in the array (outgoing link ids).""" + + +ExtraOptionAndLinkSchemaTypes = ExtraOptionSchemaTypes | ExtraLinkSchemaType +"""Union type for all extra option and link schemas.""" + + +class NeedFieldsSchemaType(AllOfSchemaType): + """ + Schema for a set of need fields of all schema types. + + This includes single value extra options, multi-value extra options, + and unresolved extra links. + + Intented to validate multiple fields on a need type. + """ + + type: NotRequired[Literal["object"]] + """All fields of a need stored in a dict (not required because it's the default).""" + properties: NotRequired[dict[str, ExtraOptionAndLinkSchemaTypes]] + required: NotRequired[list[str]] + """List of required fields in the need.""" + unevaluatedProperties: NotRequired[bool] + + +class NeedFieldsSchemaWithVersionType(NeedFieldsSchemaType, SchemaVersionType): + """Schema for a set of need fields with schema version.""" + + +class MessageRuleEnum(str, Enum): + """All known rules for debugging and validation warnings.""" + + cfg_schema_error = "cfg_schema_error" + """The user provided schema is invalid.""" + extra_option_type_error = "extra_option_type_error" + """A need extra option cannot be coerced to the type specified in the schema.""" + extra_option_success = "extra_option_success" + """Global extra option validation was successful.""" + extra_option_fail = "extra_option_fail" + """Global extra option validation failed.""" + extra_link_success = "extra_link_success" + """Global extra link validation was successful.""" + extra_link_fail = "extra_link_fail" + """Global extra link validation failed.""" + select_success = "select_success" + """ + Need validates against the select schema. + + This is not an error, but used for debugging.""" + select_fail = "select_fail" + """ + Need does not validate against the select schema + + This is not an error, but used for debugging. + """ + local_success = "local_success" + """ + Need local validation was successful. + + This is not an error, but used for debugging. + """ + local_fail = "local_fail" + """Need local validation failed.""" + network_missing_target = "network_missing_target" + """An outgoing link target cannot be resolved.""" + network_contains_too_few = "network_contains_too_few" + """minContains validation failed for the given link_schema link type.""" + network_contains_too_many = "network_contains_too_many" + """maxContains validation failed for the given link_schema link type.""" + network_items_fail = "network_item_fail" + """Need does not validate against the network item schema.""" + network_local_success = "network_local_success" + """ + Need validates against the local schema in a network context. + + This is like local_success but not on root level. + This is not an error, but used for debugging. + """ + network_local_fail = "network_local_fail" + """ + Need does not validate against the local schema in a network context. + + This is like local_fail but not on root level. + Users are interested why linked needs failed validation. + """ + network_max_nest_level = "network_max_nest_level" + """ + Maximum number of nested network validation levels reached. + """ + + +class SeverityEnum(IntEnum): + """ + Severity levels for the validation messages. + + The levels are derived from the SHACL specification. + See https://www.w3.org/TR/shacl/#severity + + config_error are user configuration errors. + """ + + none = 0 + """Succeess / no severity, used for reporting (e.g. why was no error detected).""" + info = 1 + """Lowest severity level, used for informational messages.""" + warning = 2 + """Medium severity level.""" + violation = 3 + """Highest severity level.""" + config_error = 4 + """ + Runtime detected schema config error. + + Any error of this kind should be moved to the config-inited phase so users + get notified early about config issues. + See config_utils.validate_schemas_config(). + Config errors are always reported to the user. + """ + + +USER_CONFIG_SCHEMA_SEVERITIES = [ + SeverityEnum.info, + SeverityEnum.warning, + SeverityEnum.violation, +] +""" +Severity levels that can be set in the user provided schemas and for the schema_severity config.""" + +MAP_RULE_DEFAULT_SEVERITY: Final[dict[MessageRuleEnum, SeverityEnum]] = { + MessageRuleEnum.cfg_schema_error: SeverityEnum.config_error, + MessageRuleEnum.extra_option_type_error: SeverityEnum.violation, # cannot be changed by user + MessageRuleEnum.extra_option_success: SeverityEnum.none, + MessageRuleEnum.extra_option_fail: SeverityEnum.violation, # cannot be changed by user + MessageRuleEnum.extra_link_success: SeverityEnum.none, + MessageRuleEnum.extra_link_fail: SeverityEnum.violation, # cannot be changed by user + MessageRuleEnum.select_success: SeverityEnum.none, + MessageRuleEnum.select_fail: SeverityEnum.none, + MessageRuleEnum.local_success: SeverityEnum.none, + MessageRuleEnum.local_fail: SeverityEnum.violation, + MessageRuleEnum.network_missing_target: SeverityEnum.violation, + MessageRuleEnum.network_contains_too_few: SeverityEnum.violation, + MessageRuleEnum.network_contains_too_many: SeverityEnum.violation, + # network local success/fail may or may not be of severity, + # depending on the min/max specification for the link type + # the severity is set to violation to bubble up the validations + MessageRuleEnum.network_local_success: SeverityEnum.none, # prevent it from bubbling up + MessageRuleEnum.network_local_fail: SeverityEnum.violation, # severity is handled on root schema + MessageRuleEnum.network_max_nest_level: SeverityEnum.violation, +} +""" +Default severity for each rule. + +User provided schemas can overwrite the severity of a rule. +""" + + +class ResolvedLinkSchemaType(TypedDict, total=True): + """ + Schema for resolved links between needs. + + Does not have a select field as the link itself is the selection. + There are no minItems/maxItems fields as they constrain the list length. + That should be done in the local schema as it does not require the resolution + of the linked needs. + """ + + type: NotRequired[Literal["array"]] + """Resolved needs list, can only be array and is injected automatically.""" + items: NotRequired[ValidateSchemaType] + """Schema applied to all resolved linked needs.""" + contains: NotRequired[ValidateSchemaType] + """Schema applied to one or more resolved linked needs.""" + minContains: NotRequired[int] + """Minimum number of items that validate against the contains schema.""" + maxContains: NotRequired[int] + """Maximum number of items that validate against the contains schema.""" + + +class ValidateSchemaType(TypedDict): + """ + Validation, can either be need-local or network (requires resolving all need links). + + For network, graph traversal is possible if network is selected again. + """ + + local: NotRequired[RefItemType | AllOfSchemaType | NeedFieldsSchemaType] + network: NotRequired[dict[str, ResolvedLinkSchemaType]] + + +class SchemasRootType(TypedDict): + idx: int + """Computed index in schemas list for logging.""" + id: NotRequired[str] + """String id of the schema entry, used for logging.""" + severity: NotRequired[Literal["violation", "warning", "info"]] + """Severity of the schema, defaults to violation.""" + message: NotRequired[str] + """Custom message to be shown in case of validation errors.""" + select: NotRequired[RefItemType | AllOfSchemaType | NeedFieldsSchemaType] + """Schema that selects the need type to which this schema applies.""" + validate: ValidateSchemaType + """ + Validation schema for the selected needs. + + Can be either a need-local schema or a network schema that requires resolving all need links + before validating. + """ + + +SchemasFileRootType = TypedDict( + "SchemasFileRootType", + { + "$defs": NotRequired[ + dict[ + str, + AllOfSchemaType | NeedFieldsSchemaType | ExtraOptionAndLinkSchemaTypes, + ] + ], + "schemas": NotRequired[list[SchemasRootType]], + }, +) +"""schemas.json root type.""" diff --git a/sphinx_needs/schema/config_utils.py b/sphinx_needs/schema/config_utils.py new file mode 100644 index 000000000..bc7a2c87c --- /dev/null +++ b/sphinx_needs/schema/config_utils.py @@ -0,0 +1,613 @@ +"""Utilities for schemas in Sphinx Needs configuration (mainly validate).""" + +from __future__ import annotations + +import re +from typing import Any, Literal, cast + +from typeguard import TypeCheckError, check_type + +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import NeedsCoreFields +from sphinx_needs.exceptions import NeedsConfigException +from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.schema.config import ( + EXTRA_OPTION_BASE_TYPES_STR, + EXTRA_OPTION_BASE_TYPES_TYPE, + USER_CONFIG_SCHEMA_SEVERITIES, + AllOfSchemaType, + ExtraLinkSchemaType, + ExtraOptionAndLinkSchemaTypes, + ExtraOptionBooleanSchemaType, + ExtraOptionIntegerSchemaType, + ExtraOptionMultiValueSchemaType, + ExtraOptionNumberSchemaType, + ExtraOptionStringSchemaType, + NeedFieldsSchemaType, + SchemasRootType, + ValidateSchemaType, +) + +log = get_logger(__name__) + + +def has_any_global_extra_schema_defined(needs_config: NeedsSphinxConfig) -> bool: + """ + Check if any extra option or extra link has a schema defined. + + :return: True if any extra option or extra link has a schema set, False otherwise. + """ + # Check extra options + for option_value in needs_config.extra_options.values(): + if option_value.schema is not None: + return True + + # Check extra links + for extra_link in needs_config.extra_links: + if "schema" in extra_link and extra_link["schema"] is not None: + return True + + return False + + +def validate_schemas_config(needs_config: NeedsSphinxConfig) -> None: + """ + Validates schemas for extra_options, extra_links, and schemas in needs_config. + + Invokes the typeguard library to re-use existing type hints. + Mostly checking for TypedDicts. + """ + extra_option_type_map = inject_type_and_validate_extra_option_schemas(needs_config) + validate_extra_link_schemas(needs_config) + inject_type_extra_link_schemas(needs_config) + + # check for disallowed regex patterns + validate_regex_patterns_extra_options(needs_config) + validate_regex_patterns_extra_links(needs_config) + + # emit warning if global or schema definitions are used + if ( + has_any_global_extra_schema_defined(needs_config) + or needs_config.schema_definitions + ): + log_warning( + log, + "Schema interface and validation are still in beta. Interface and validation logic may change " + "when moving to a typed core implementation.", + "beta", + location=None, + ) + + if not needs_config.schema_definitions: + # nothing to validate, resolve or inject + return + + if not isinstance(needs_config.schema_definitions, dict): + raise NeedsConfigException("Schemas entry 'schemas' is not a dict.") + + if "schemas" not in needs_config.schema_definitions: + # nothing to validate (also no need to check for $defs) + return + + if not isinstance(needs_config.schema_definitions["schemas"], list): + raise NeedsConfigException( + "The 'schemas' key in needs_schema_definitions is not a list." + ) + + if not needs_config.schema_definitions["schemas"]: + # nothing to validate + return + + # resolve $ref entries in schema; this is done before the typeguard check + # to check the final schema structure and give feedback based on it + if "$defs" in needs_config.schema_definitions: + defs = needs_config.schema_definitions["$defs"] + resolve_refs(defs, needs_config.schema_definitions, set()) + + # set idx for logging purposes, it's part of the schema name + for idx, schema in enumerate(needs_config.schema_definitions["schemas"]): + schema["idx"] = idx + + # inject extra option types to schema, this is required before typeguard check + # as the type field switches between the TypedDict options; + # this improves error messages + extra_links = {link["option"] for link in needs_config.extra_links} + core_field_type_map = get_core_field_type_map() + for schema in needs_config.schema_definitions["schemas"]: + schema_name = get_schema_name(schema) + populate_field_type( + schema, + schema_name, + core_field_type_map, + extra_option_type_map, + extra_links, + ) + + # validate schemas against type hints at runtime + for schema in needs_config.schema_definitions["schemas"]: + try: + check_type(schema, SchemasRootType) + except TypeCheckError as exc: + schema_name = get_schema_name(schema) + raise NeedsConfigException( + f"Schemas entry '{schema_name}' is not valid:\n{exc}" + ) from exc + + # check if network links are defined as extra links + check_network_links_against_extra_links( + needs_config.schema_definitions["schemas"], + extra_links, + ) + + # check severity and inject default if not set + validate_severity(needs_config.schema_definitions["schemas"]) + + # check safe regex patterns of schemas + validate_regex_patterns_schemas(needs_config) + + +def get_schema_name(schema: SchemasRootType) -> str: + """Get a human-readable name for the schema considering its optional id and list index.""" + schema_id = schema.get("id") + idx = schema["idx"] + schema_name = f"{schema_id}[{idx}]" if schema_id else f"[{idx}]" + return schema_name + + +def get_core_field_type_map() -> dict[str, EXTRA_OPTION_BASE_TYPES_TYPE]: + core_field_type_map: dict[str, EXTRA_OPTION_BASE_TYPES_TYPE] = {} + for core_field_name, core_field in NeedsCoreFields.items(): + if "type" in core_field["schema"]: + core_type = core_field["schema"]["type"] + if isinstance(core_type, str) and core_type in EXTRA_OPTION_BASE_TYPES_STR: + core_field_type_map[core_field_name] = cast( + EXTRA_OPTION_BASE_TYPES_TYPE, core_type + ) + elif ( + isinstance(core_type, list) + and "null" in core_type + and len(core_type) == 2 + ): + non_null_type = next(t for t in core_type if t != "null") + if non_null_type in EXTRA_OPTION_BASE_TYPES_STR: + core_field_type_map[core_field_name] = non_null_type + else: + # ignore the core field as the type is not supported by the schema validation + pass + else: + raise NeedsConfigException( + f"Core field '{core_field_name}' does not have a type defined in schema." + ) + + return core_field_type_map + + +def validate_severity(schemas: list[SchemasRootType]) -> None: + severity_values = {item.name for item in USER_CONFIG_SCHEMA_SEVERITIES} + for schema in schemas: + schema_id = schema.get("id") + idx = schema["idx"] + schema_name = f"{schema_id}[{idx}]" if schema_id else f"[{idx}]" + + # check severity and + if "severity" not in schema: + # set default severity + schema["severity"] = "violation" + else: + if schema["severity"] not in severity_values: + raise NeedsConfigException( + f"Schemas entry '{schema_name}' has unknown severity: {schema['severity']}" + ) + + +def validate_regex_patterns_extra_options(needs_config: NeedsSphinxConfig) -> None: + """Validate regex patterns of extra options.""" + for option_name, option in needs_config.extra_options.items(): + if ( + option.schema is None + or not option.schema + or option.schema.get("type") != "string" + ): + continue + validate_schema_patterns(option.schema, f"extra_options.{option_name}.schema") + + +def validate_regex_patterns_extra_links(needs_config: NeedsSphinxConfig) -> None: + """Validate regex patterns of extra links.""" + + for extra_link in needs_config.extra_links: + if "schema" not in extra_link or extra_link["schema"] is None: + continue + + validate_schema_patterns( + extra_link["schema"], f"extra_links.{extra_link['option']}.schema" + ) + + +def validate_regex_patterns_schemas(needs_config: NeedsSphinxConfig) -> None: + """Validate regex patterns of schemas.""" + for schema in needs_config.schema_definitions["schemas"]: + schema_name = get_schema_name(schema) + try: + validate_schema_patterns(schema, f"schemas.{schema_name}") + except NeedsConfigException as exc: + raise NeedsConfigException( + f"Schemas entry '{schema_name}' is not valid:\n{exc}" + ) from exc + + +def validate_extra_link_schemas(needs_config: NeedsSphinxConfig) -> None: + """Validate types of extra links in needs_config and set default.""" + for extra_link in needs_config.extra_links: + if "schema" not in extra_link or extra_link["schema"] is None: + continue + try: + check_type(extra_link["schema"], ExtraLinkSchemaType) + except TypeCheckError as exc: + raise NeedsConfigException( + f"Schema for extra link '{extra_link['option']}' is not valid:\n{exc}" + ) from exc + + +def inject_type_extra_link_schemas(needs_config: NeedsSphinxConfig) -> None: + """Inject the optional type field to extra link schemas.""" + type_inject_fields = ["contains", "items"] + for extra_link in needs_config.extra_links: + if "schema" not in extra_link or extra_link["schema"] is None: + continue + for field in type_inject_fields: + if ( + field in extra_link["schema"] + and "type" not in extra_link["schema"][field] # type: ignore[literal-required] + ): + # set string as default + extra_link["schema"][field]["type"] = "string" # type: ignore[literal-required] + + +def inject_type_and_validate_extra_option_schemas( + needs_config: NeedsSphinxConfig, +) -> dict[str, EXTRA_OPTION_BASE_TYPES_TYPE]: + """ + Inject missing types of extra options in needs_config and validate the config. + + :return: Map of extra option names to their types as strings. + """ + string_type_to_typeddict_map = { + "string": ExtraOptionStringSchemaType, + "boolean": ExtraOptionBooleanSchemaType, + "integer": ExtraOptionIntegerSchemaType, + "number": ExtraOptionNumberSchemaType, + "array": ExtraOptionMultiValueSchemaType, + } + option_type_map: dict[str, EXTRA_OPTION_BASE_TYPES_TYPE] = {} + for option, value in needs_config.extra_options.items(): + if value.schema is None: + # nothing to check, leave it at None so it is explicitly unset; + # remember the type in case it needs to be injected to + # needs_config.schema_definitions later + option_type_map[option] = "string" + continue + if "type" not in value.schema: + # set string as default; + # mypy is confused because it does not infer that + # type decides about which TypedDict applies + value.schema["type"] = "string" # type: ignore[arg-type] + option_type_ = value.schema["type"] + if option_type_ not in string_type_to_typeddict_map: + raise NeedsConfigException( + f"Schema for extra option '{option}' has invalid type: {option_type_}. " + f"Allowed types are: {', '.join(string_type_to_typeddict_map.keys())}" + ) + try: + # below check happens late because the type system does not know that + # the type field determines the required TypedDict which leads + # to improved error messages + check_type( + value.schema, + string_type_to_typeddict_map[option_type_], + ) + except TypeCheckError as exc: + raise NeedsConfigException( + f"Schema for extra option '{option}' is not valid:\n{exc}" + ) from exc + option_type_map[option] = option_type_ + return option_type_map + + +def check_network_links_against_extra_links( + schemas: list[SchemasRootType], extra_links: set[str] +) -> None: + """Check if network links are defined as extra links.""" + for schema in schemas: + validate_schemas: list[ValidateSchemaType] = [schema["validate"]] + while validate_schemas: + validate_schema = validate_schemas.pop() + if "network" in validate_schema: + for link_type, resolved_link_schema in validate_schema[ + "network" + ].items(): + if link_type not in extra_links: + schema_name = get_schema_name(schema) + raise NeedsConfigException( + f"Schema '{schema_name}' defines an unknown network link type" + f" '{link_type}'." + ) + if not isinstance(resolved_link_schema, dict): + schema_name = get_schema_name(schema) + raise NeedsConfigException( + f"Schema '{schema_name}' defines a network link type '{link_type}' " + "but its value a not dict." + ) + nested_fields = ["contains", "items"] + for field in nested_fields: + if field in resolved_link_schema: + if not isinstance(resolved_link_schema[field], dict): # type: ignore[literal-required] + schema_name = get_schema_name(schema) + raise NeedsConfigException( + f"Schema '{schema_name}' defines a network link type '{link_type}' " + f"but its '{field}' value is not a dict." + ) + validate_schemas.append(resolved_link_schema[field]) # type: ignore[literal-required] + + +def resolve_refs( + defs: dict[ + str, + AllOfSchemaType | NeedFieldsSchemaType | ExtraOptionAndLinkSchemaTypes, + ], + curr_item: Any, + circular_refs_guard: set[str], + is_defs: bool = False, +) -> None: + """Recursively resolve and replace $ref entries in schemas.""" + if isinstance(curr_item, dict): + if "$ref" in curr_item: + if len(curr_item) == 1: + ref_raw = curr_item["$ref"] + if not isinstance(ref_raw, str): + raise NeedsConfigException( + f"Invalid $ref value: {ref_raw}, expected a string." + ) + if not ref_raw.startswith("#/$defs/"): + raise NeedsConfigException( + f"Invalid $ref value: {ref_raw}, expected to start with '#/$defs/'." + ) + ref = ref_raw[8:] # Remove '#/$defs/' prefix + if ref not in defs: + raise NeedsConfigException( + f"Reference '{ref}' not found in definitions." + ) + if ref in circular_refs_guard: + raise NeedsConfigException( + f"Circular reference detected for '{ref}'." + ) + circular_refs_guard.add(ref) + curr_item.clear() + curr_item.update(defs[ref]) + resolve_refs(defs, curr_item, circular_refs_guard) + circular_refs_guard.remove(ref) + else: + raise NeedsConfigException( + f"Invalid $ref entry, expected a single $ref key: {curr_item}" + ) + for key, value in curr_item.items(): + if isinstance(value, dict): + if is_defs: + circular_refs_guard.add(key) + resolve_refs(defs, value, circular_refs_guard, is_defs=(key == "$defs")) + if is_defs: + circular_refs_guard.remove(key) + if isinstance(value, list): + for item in value: + resolve_refs(defs, item, circular_refs_guard) + elif isinstance(curr_item, list): + for item in curr_item: + resolve_refs(defs, item, circular_refs_guard) + + +def populate_field_type( + curr_item: Any, + schema_name: str, + core_field_type_map: dict[ + str, Literal["string", "integer", "number", "boolean", "array"] + ], + extra_option_type_map: dict[ + str, Literal["string", "integer", "number", "boolean", "array"] + ], + extra_links: set[str], +) -> None: + """ + Inject field type into select / validate fields in schema. + + Users might not be aware that schema validation will not complain if e.g. + a minimium is set for an integer, but if the type is not set, any json schema + validator will not complain. Types are available from needs_config.extra_options. + If the field is of type extra option but no type is defined, string is assumed. + If the field appears as link and no type is defined, array of string is assumed. + """ + # TODO(Marco): this function could be improved to run on defined types, not on Any; + # this would make the detection of 'properties' safer + if isinstance(curr_item, dict): + # looking for 'properties' is a bit dangerous + if ( + "properties" in curr_item + and isinstance(curr_item["properties"], dict) + and all(isinstance(k, str) for k in curr_item["properties"]) + and all(isinstance(v, dict) for v in curr_item["properties"].values()) + ): + if "type" not in curr_item: + # need dictionary type + curr_item["type"] = "object" + for key, value in curr_item["properties"].items(): + if "type" not in value: + if key in extra_option_type_map: + value["type"] = extra_option_type_map[key] + elif key in extra_links: + value["type"] = "array" + if "contains" not in value: + value["contains"] = {"type": "string"} + else: + if "type" not in value["contains"]: + value["contains"]["type"] = "string" + elif key in core_field_type_map: + value["type"] = core_field_type_map[key] + else: + raise NeedsConfigException( + f"Config error in schema '{schema_name}': Field '{key}' does not have a type defined. " + "Please define the type in extra_options or extra_links." + ) + else: + if ( + key in extra_option_type_map + and value["type"] != extra_option_type_map[key] + ): + raise NeedsConfigException( + f"Config error in schema '{schema_name}': Field '{key}' has type '{value['type']}', " + f"but expected '{extra_option_type_map[key]}'." + ) + elif key in extra_links and value["type"] != "array": + raise NeedsConfigException( + f"Config error in schema '{schema_name}': Field '{key}' has type '{value['type']}', " + "but expected 'array'." + ) + elif ( + key in core_field_type_map + and value["type"] != core_field_type_map[key] + ): + raise NeedsConfigException( + f"Config error in schema '{schema_name}': Field '{key}' has type '{value['type']}', " + f"but expected '{core_field_type_map[key]}'." + ) + else: + # type is already set, nothing to do; + # mismatching types will be checked by typeguard + pass + + else: + for value in curr_item.values(): + populate_field_type( + value, + schema_name, + core_field_type_map, + extra_option_type_map, + extra_links, + ) + elif isinstance(curr_item, list): + for value in curr_item: + populate_field_type( + value, + schema_name, + core_field_type_map, + extra_option_type_map, + extra_links, + ) + + +class UnsafePatternError(ValueError): + """Raised when a regex pattern contains unsafe constructs.""" + + def __init__(self, pattern: str, reason: str) -> None: + super().__init__(f"Unsafe regex pattern '{pattern}': {reason}") + self.pattern: str = pattern + self.reason: str = reason + + +def validate_regex_pattern(pattern: str) -> None: + """ + Validate that a regex pattern uses only basic features for cross-platform use. + + Only allows basic regex constructs that work consistently across + various regex engines (e.g. Python, Rust, SQLite, Kuzu). + + :param pattern: The regex pattern to validate + :raises UnsafePatternError: If the pattern contains unsupported constructs + """ + # First check if the pattern compiles in Python + try: + re.compile(pattern) + except re.error as exc: + raise UnsafePatternError(pattern, f"invalid regex syntax: {exc}") from exc + + # Check for specific unsafe constructs first (more precise detection) + unsafe_constructs = { + r"\(\?[=!<]": "lookahead/lookbehind assertions", + r"\\[1-9]": "backreferences", + r"\(\?[^:]": "special groups (other than non-capturing)", + r"\\[pPdDsSwWbBAZ]": "character class shortcuts and word boundaries", + r"\(\?\#": "comments", + r"\\[uUxc]": "Unicode and control character escapes", + r"\(\?\&": "subroutine calls", + r"\(\?\+": "relative subroutine calls", + r"\(\?\(": "conditional patterns", + r"\(\?>": "atomic groups", + r"[+*?]\+": "possessive quantifiers", + r"\(\?R\)": "recursive patterns", + } + + for construct_pattern, description in unsafe_constructs.items(): + if re.search(construct_pattern, pattern): + raise UnsafePatternError(pattern, f"contains {description}") + + # Additional validation for nested quantifiers that could cause backtracking + if re.search(r"\([^)]*[+*?][^)]*\)[+*?]", pattern): + raise UnsafePatternError( + pattern, "contains nested quantifiers that may cause backtracking" + ) + + # Define allowed basic regex constructs using allowlist approach + allowed_pattern = r""" + ^ # Start of string + (?: # Non-capturing group for alternatives + [^\\()[\]{}|+*?^$] # Literal characters (not special) + |\\[\\()[\]{}|+*?^$nrtvfs] # Basic escaped characters and whitespace + |\[[^\]]*\] # Character classes [abc], [a-z], [^abc] + |\(\?: # Non-capturing groups (?:...) + |\( # Capturing groups (...) + |\) # Group closing + |\| # Alternation + |[+*?] # Basic quantifiers + |\{[0-9]+(?:,[0-9]*)?\} # Counted quantifiers {n}, {n,}, {n,m} + |\^ # Start anchor + |\$ # End anchor + |\. # Any character + )* # Zero or more occurrences + $ # End of string + """ + + # Remove whitespace and comments from the validation pattern + validation_regex = re.sub(r"\s+|#.*", "", allowed_pattern) + + if not re.match(validation_regex, pattern): + raise UnsafePatternError(pattern, "contains unsupported regex construct") + + +def validate_schema_patterns(schema: Any, path: str = "") -> None: + """ + Recursively validate all regex patterns in a schema. + + :param schema: The schema dictionary to validate + :param path: Current path in the schema (for error reporting) + :raises UnsafePatternError: If any pattern is unsafe + """ + if isinstance(schema, dict): + if "type" in schema and schema["type"] == "string" and "pattern" in schema: + try: + validate_regex_pattern(schema["pattern"]) + except UnsafePatternError as exc: + raise NeedsConfigException( + f"Unsafe pattern '{exc.pattern}' at '{path}': {exc.reason}" + ) from exc + for key, value in schema.items(): + current_path = f"{path}.{key}" if path else key + if isinstance(value, dict): + validate_schema_patterns(value, current_path) + elif isinstance(value, list): + for i, item in enumerate(value): + item_path = f"{current_path}[{i}]" + if isinstance(item, dict): + validate_schema_patterns(item, item_path) + elif isinstance(schema, list): + for i, item in enumerate(schema): + current_path = f"{path}[{i}]" if path else f"[{i}]" + if isinstance(item, dict | list): + validate_schema_patterns(item, current_path) diff --git a/sphinx_needs/schema/core.py b/sphinx_needs/schema/core.py new file mode 100644 index 000000000..63e70497a --- /dev/null +++ b/sphinx_needs/schema/core.py @@ -0,0 +1,700 @@ +"""SN extension for schema validation.""" + +from __future__ import annotations + +from typing import Any, cast + +from jsonschema import Draft202012Validator, FormatChecker, ValidationError + +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import NeedsInfoType +from sphinx_needs.schema.config import ( + MAP_RULE_DEFAULT_SEVERITY, + MAX_NESTED_NETWORK_VALIDATION_LEVELS, + MessageRuleEnum, + NeedFieldsSchemaType, + NeedFieldsSchemaWithVersionType, + SchemasRootType, + SeverityEnum, + ValidateSchemaType, +) +from sphinx_needs.schema.config_utils import get_schema_name +from sphinx_needs.schema.reporting import ( + OntologyWarning, + ValidateNeedMessageType, + ValidateNeedType, + filter_warnings_severity, + save_debug_files, +) +from sphinx_needs.schema.utils import get_properties_from_schema +from sphinx_needs.views import NeedsView + +# TODO(Marco): error for conflicting unevaluatedProperties + + +_schema_version = "https://json-schema.org/draft/2020-12/schema" +""" +JSON schema metaversion to use. + +The implementation requires at least draft 2019-09 as unevaluatedProperties was added there. +""" + +_extra_option_schemas: NeedFieldsSchemaType = { + "type": "object", + "properties": {}, +} +""" +Combined static schema information for all extra options. + +This is valid for all need types and conditions. +""" + +_extra_link_schemas: NeedFieldsSchemaType = { + "type": "object", + "properties": {}, +} +""" +Combined static schema information for all extra link options. + +This is valid for all need types and conditions. +""" + +_needs_schema: dict[str, Any] = {} +"""The needs schema as it would be written to needs.json, generated by generate_needs_schema().""" + + +def merge_static_schemas(config: NeedsSphinxConfig) -> bool: + """ + Merge static extra_option.schema and extra_links.schema items. + + Goal is to have a single validation per need for these. + Writes to the global _extra_option_schemas / _extra_link_schemas variables. + """ + any_found = False + extra_option_properties: NeedFieldsSchemaType = {"properties": {}} + for name, option in config.extra_options.items(): + if option.schema is not None: + any_found = True + extra_option_properties["properties"][name] = option.schema + _extra_option_schemas["properties"] = extra_option_properties["properties"] + + extra_link_properties: NeedFieldsSchemaType = {"properties": {}} + for link in config.extra_links: + if "schema" in link and link["schema"] is not None: + any_found = True + extra_link_properties["properties"][link["option"]] = link["schema"] + _extra_link_schemas["properties"] = extra_link_properties["properties"] + return any_found + + +def validate_need( + config: NeedsSphinxConfig, + need: NeedsInfoType, + needs: NeedsView, + type_schemas: list[SchemasRootType], +) -> list[OntologyWarning]: + """ + Validate a single need against all extra option, link option and type schemas. + + The loop creates reports of type ReportSingleType that follow the schema + definition structure. The reports are then converted to + NestedWarningType objects presented to the user. + """ + all_warnings: list[OntologyWarning] = [] + + if _extra_option_schemas.get("properties"): + # check schemas of extra options and extra links for the need + new_warnings_options = get_ontology_warnings( + config, + need, + _extra_option_schemas, + fail_rule=MessageRuleEnum.extra_option_fail, + success_rule=MessageRuleEnum.extra_option_success, + schema_path=["extra_options", "schema"], + need_path=[need["id"]], + ) + save_debug_files(config, new_warnings_options) + all_warnings.extend(filter_warnings_severity(config, new_warnings_options)) + + if _extra_link_schemas.get("properties"): + new_warnings_links = get_ontology_warnings( + config, + need, + _extra_link_schemas, + fail_rule=MessageRuleEnum.extra_link_fail, + success_rule=MessageRuleEnum.extra_link_success, + schema_path=["extra_links", "schema"], + need_path=[need["id"]], + ) + save_debug_files(config, new_warnings_links) + all_warnings.extend(filter_warnings_severity(config, new_warnings_links)) + + for type_schema in type_schemas: + # maintain state for nested network validation + schema_name = get_schema_name(type_schema) + if type_schema.get("select"): + new_warnings_select = get_ontology_warnings( + config, + need, + cast(NeedFieldsSchemaType, type_schema["select"]), + fail_rule=MessageRuleEnum.select_fail, + success_rule=MessageRuleEnum.select_success, + schema_path=[schema_name, "select"], + need_path=[need["id"]], + ) + save_debug_files(config, new_warnings_select) + # filter warnings not required as select has severity none + if any_not_of_rule(new_warnings_select, MessageRuleEnum.select_success): + # need is not selected + continue + + user_severity = ( + SeverityEnum[type_schema["severity"]] if "severity" in type_schema else None + ) + local_network_schema: ValidateSchemaType = {} + if "local" in type_schema["validate"]: + local_network_schema["local"] = type_schema["validate"]["local"] + if "network" in type_schema["validate"]: + local_network_schema["network"] = type_schema["validate"]["network"] + _, new_warnings_recurse = recurse_validate_type_schmemas( + config, + need, + needs, + user_message=type_schema.get("message"), + schema=local_network_schema, + severity=user_severity, + schema_path=[schema_name], + need_path=[need["id"]], + recurse_level=0, + ) + all_warnings.extend(new_warnings_recurse) + + return all_warnings + + +def recurse_validate_type_schmemas( + config: NeedsSphinxConfig, + need: NeedsInfoType, + needs: NeedsView, + user_message: str | None, + schema: ValidateSchemaType, + schema_path: list[str], + need_path: list[str], + recurse_level: int, + severity: SeverityEnum | None = None, +) -> tuple[bool, list[OntologyWarning]]: + """ + Recursively validate a need against type schemas. + + The bool success bit indicates whether local and downstream validation were successful. + The returned list of OntologyWarning objects contains warnings + that are already filtered by user severity and can directly be used for user reporting. + """ + if recurse_level > MAX_NESTED_NETWORK_VALIDATION_LEVELS: + rule = MessageRuleEnum.network_max_nest_level + warning: OntologyWarning = { + "rule": rule, + "severity": MAP_RULE_DEFAULT_SEVERITY[rule], + "validation_message": ( + f"Maximum network validation recursion level {MAX_NESTED_NETWORK_VALIDATION_LEVELS} reached." + ), + "need": need, + "schema_path": schema_path, + "need_path": need_path, + } + if user_message is not None: + warning["user_message"] = user_message + return False, [warning] + + warnings: list[OntologyWarning] = [] + success = True + if "local" in schema: + rule_success = ( + MessageRuleEnum.local_success + if recurse_level == 0 + else MessageRuleEnum.network_local_success + ) + rule_fail = ( + MessageRuleEnum.local_fail + if recurse_level == 0 + else MessageRuleEnum.network_local_fail + ) + warnings_local = get_ontology_warnings( + config, + need, + cast(NeedFieldsSchemaType, schema["local"]), # refs were replaced already + rule_fail, + rule_success, + schema_path=[*schema_path, "local"], + need_path=need_path, + user_message=user_message if recurse_level == 0 else None, + ) + save_debug_files(config, warnings_local) + warnings_local_filtered = filter_warnings_severity( + config, warnings_local, severity + ) + warnings.extend(warnings_local_filtered) + if any_not_of_rule(warnings_local, rule_success): + success = False + if "network" in schema: + for link_type, link_schema in schema["network"].items(): + items_targets_ok: list[str] = [] + """List of target need ids for items validation that passed.""" + items_targets_nok: list[str] = [] + """List of target need ids for items validation that failed.""" + items_warnings_per_target: dict[str, list[OntologyWarning]] = {} + """Map of target need id to warnings for failed items validation.""" + contains_targets_ok: list[str] = [] + """List of target need ids for contains validation that passed.""" + contains_targets_nok: list[str] = [] + """List of target need ids for contains validation that failed.""" + contains_warnings_per_target: dict[str, list[OntologyWarning]] = {} + """Map of target need id to warnings for failed contains validation.""" + for target_need_id in need[link_type]: # type: ignore[literal-required] + # collect all downstream warnings for broken links, items and contains + # evaluation happens later + try: + target_need = needs[target_need_id] + except KeyError: + # target need does not exist (broken link) + rule = MessageRuleEnum.network_missing_target + msg = f"Broken link of type '{link_type}' to '{target_need_id}'" + # report it directly, it's not a minmax warning and the target need is ignored + # in the minmax checks + warnings.append( + { + "rule": rule, + "severity": get_severity(rule, severity), + "validation_message": msg, + "need": need, + "schema_path": [*schema_path, "network", link_type], + "need_path": [*need_path, link_type], + } + ) + if recurse_level == 0 and user_message is not None: + warnings[-1]["user_message"] = user_message + continue + + # Handle items validation - all items must pass + if link_schema.get("items"): + new_success, new_warnings = recurse_validate_type_schmemas( + config=config, + need=target_need, + needs=needs, + user_message=user_message, + schema=link_schema["items"], + schema_path=[*schema_path, link_type], + need_path=[*need_path, link_type, target_need_id], + recurse_level=recurse_level + 1, + severity=severity, + ) + if new_success: + items_targets_ok.append(target_need_id) + else: + items_targets_nok.append(target_need_id) + items_warnings_per_target[target_need_id] = new_warnings + else: + items_targets_ok.append(target_need_id) + + # Handle contains validation - at least some items must pass + if link_schema.get("contains"): + new_success, new_warnings = recurse_validate_type_schmemas( + config=config, + need=target_need, + needs=needs, + user_message=user_message, + schema=link_schema["contains"], + schema_path=[*schema_path, link_type], + need_path=[*need_path, link_type, target_need_id], + recurse_level=recurse_level + 1, + severity=severity, + ) + if new_success: + contains_targets_ok.append(target_need_id) + else: + contains_targets_nok.append(target_need_id) + contains_warnings_per_target[target_need_id] = new_warnings + else: + contains_targets_ok.append(target_need_id) + + # Check items validation results + items_success = True + if link_schema.get("items") and items_targets_nok: + items_success = False + # Add warnings for failed items validation + items_nok_warnings = [ + warning + for target_id in items_targets_nok + for warning in items_warnings_per_target[target_id] + ] + rule = MessageRuleEnum.network_items_fail + msg = ( + f"Items validation failed for links of type '{link_type}' " + f"to {', '.join(items_targets_nok)}" + ) + if items_targets_ok: + msg += f" / ok: {', '.join(items_targets_ok)}" + if items_targets_nok: + msg += f" / nok: {', '.join(items_targets_nok)}" + warning = { + "rule": rule, + "severity": get_severity(rule, severity), + "validation_message": msg, + "need": need, + "schema_path": [ + *schema_path, + "validate", + "network", + link_type, + "items", + ], + "need_path": [*need_path, link_type], + "children": items_nok_warnings, # user is interested in these + } + if recurse_level == 0 and user_message is not None: + # user message only added to the root validation + warning["user_message"] = user_message + warnings.extend( + filter_warnings_severity(config, items_nok_warnings, severity) + ) + + # Check contains validation results + contains_success = True + if link_schema.get("contains"): + contains_warnings: list[OntologyWarning] = [] + contains_cnt_ok = len(contains_targets_ok) + contains_cnt_nok = len(contains_targets_nok) + min_contains = 1 # default if minContains is not set + if "minContains" in link_schema: + min_contains = link_schema["minContains"] + if contains_cnt_ok < min_contains: + rule = MessageRuleEnum.network_contains_too_few + msg = f"Too few valid links of type '{link_type}' ({contains_cnt_ok} < {min_contains})" + if contains_cnt_ok > 0: + msg += f" / ok: {', '.join(contains_targets_ok)}" + if contains_cnt_nok > 0: + msg += f" / nok: {', '.join(contains_targets_nok)}" + contains_nok_warnings = [ + warning + for target_id in contains_targets_nok + for warning in contains_warnings_per_target[target_id] + ] + contains_warnings.append( + { + "rule": rule, + "severity": get_severity(rule, severity), + "validation_message": msg, + "need": need, + "schema_path": [ + *schema_path, + "validate", + "network", + link_type, + ], + "need_path": [*need_path, link_type], + "children": contains_nok_warnings, # user is interested in these + } + ) + if recurse_level and user_message is not None: + contains_warnings[-1]["user_message"] = user_message + contains_success = False + if "maxContains" in link_schema: + max_contains = link_schema["maxContains"] + if contains_cnt_ok > max_contains: + rule = MessageRuleEnum.network_contains_too_many + msg = f"Too many valid links of type '{link_type}' ({contains_cnt_ok} > {max_contains})" + if contains_cnt_ok > 0: + msg += f" / ok: {', '.join(contains_targets_ok)}" + if contains_cnt_nok > 0: + msg += f" / nok: {', '.join(contains_targets_nok)}" + contains_warnings.append( + { + "rule": rule, + "severity": get_severity(rule, severity), + "validation_message": msg, + "need": need, + "schema_path": [ + *schema_path, + "validate", + "network", + link_type, + ], + "need_path": [*need_path, link_type], + # children not passed, no interest in too much success + } + ) + if recurse_level == 0 and user_message is not None: + # user message only added to the root validation + contains_warnings[-1]["user_message"] = user_message + contains_success = False + + filtered_contains_warnings = filter_warnings_severity( + config, contains_warnings, severity + ) + warnings.extend(filtered_contains_warnings) + + # Overall success requires both items and minmax validation to pass + if not (items_success and contains_success): + success = False + + return success, warnings + + +def validate_local_need( + config: NeedsSphinxConfig, + need: NeedsInfoType, + schema: NeedFieldsSchemaType, +) -> ValidateNeedType: + """ + Validate a single need against a given fields schema. + + :param rule: The validation context rule, e.g. MessageRuleEnum.local_success. + Can be overridden locally for type coercion errors or schema errors. + """ + final_schema: NeedFieldsSchemaWithVersionType = { + "$schema": _schema_version, + "type": "object", + } + if "properties" in schema: + final_schema["properties"] = schema["properties"] + if "allOf" in schema: + final_schema["allOf"] = schema["allOf"] + if "required" in schema: + final_schema["required"] = schema["required"] + if "unevaluatedProperties" in schema: + final_schema["unevaluatedProperties"] = schema["unevaluatedProperties"] + + reduced_need = reduce_need(config, need, final_schema) + report: ValidateNeedType = { + "final_schema": final_schema, + "reduced_need": reduced_need, + "messages": [], + } + validation_warnings = get_localschema_errors(reduced_need, dict(final_schema)) + if validation_warnings: + for warning in validation_warnings: + field = ".".join([str(x) for x in warning.path]) + report_message: ValidateNeedMessageType = { + "field": field, + "message": warning.message, + "schema_path": [str(item) for item in warning.schema_path], + } + report["messages"].append(report_message) + + return report + + +def reduce_need( + config: NeedsSphinxConfig, + need: NeedsInfoType, + json_schema: NeedFieldsSchemaWithVersionType, +) -> dict[str, Any]: + """ + Reduce a need to its relevant fields for validation in a specific schema context. + + The reduction is required to separated actively set fields from defaults. + Also internal fields shall be removed, if they are not actively used in the schema. + This is required to make unevaluatedProperties work as expected which disallows + additional fields. + + Needs can be reduced in multiple contexts as the need can be primary target of validation + or it can be a link target which might mean only a single field shall be checked for a + specific value. + + Fields are kept + - if they are extra fields and differ from their default value + - if they are links and the list is not empty + - if they are part of the user provided schema + + The function coerces extra option strings to their specified JSON schema types: + -> integer -> int + -> number -> float + -> boolean -> bool + + :param need: The need to reduce. + :param json_schema: The user provided and merged JSON merge. + :raises ValueError: If a field cannot be coerced to its specified type. + """ + reduced_need: dict[str, Any] = {} + schema_properties = get_properties_from_schema(json_schema) + for field, value in need.items(): + keep = False + schema_field = _needs_schema[field] + + if schema_field["field_type"] == "extra" and not ( + "default" in schema_field and value == schema_field["default"] + ): + # keep explicitly set extra options + keep = True + + if schema_field["field_type"] == "links" and value: + # keep non-empty link fields + keep = True + + if ( + schema_field["field_type"] == "core" + and field in schema_properties + and not ("default" in schema_field and value == schema_field["default"]) + ): + # keep core field, it has no default or differs from the default and + # is part of the user provided schema + keep = True + + if keep: + coerced_value = value + if schema_field["field_type"] == "extra" and field in config.extra_options: + option_schema = config.extra_options[field].schema + if ( + option_schema is not None + and "type" in option_schema + and option_schema["type"] != "string" + ): + type_ = option_schema["type"] + if not isinstance(value, str): + raise TypeCoerceError( + f"Field '{field}': cannot coerce '{value}' (type: {type(value)}) to {type_}", + field=field, + ) + try: + if type_ == "integer": + coerced_value = int(value) + elif type_ == "number": + coerced_value = float(value) + except ValueError as exc: + raise TypeCoerceError( + f"Field '{field}': cannot coerce '{value}' to {type_}", + field=field, + ) from exc + if type_ == "boolean": + truthy = {"true", "yes", "y", "on", "1"} + falsy = {"false", "no", "n", "off", "0"} + if value.lower() in truthy: + coerced_value = True + elif value.lower() in falsy: + coerced_value = False + else: + raise TypeCoerceError( + f"Field '{field}': cannot coerce '{value}' to boolean", + field=field, + ) + reduced_need[field] = coerced_value + + return reduced_need + + +class TypeCoerceError(ValueError): + """Store also the field name for reporting.""" + + def __init__(self, message: str, field: str) -> None: + super().__init__(message) + self.field = field + + +def get_localschema_errors( + need: dict[str, Any], schema: dict[str, Any] +) -> list[ValidationError]: + """ + Validate a need against a schema and return a list of errors. + + :raises jsonschema_rs.ValidationError: If the schema is invalid cannot be built. + """ + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + return list(validator.iter_errors(instance=need)) + + +def get_severity( + rule: MessageRuleEnum, user_severity: SeverityEnum | None = None +) -> SeverityEnum: + """Get rule severity, select the default severity if not overridden by a schema.""" + if user_severity is not None: + return user_severity + return MAP_RULE_DEFAULT_SEVERITY[rule] + + +def any_not_of_rule(warnings: list[OntologyWarning], rule: MessageRuleEnum) -> bool: + """ + Check if any warning in the list does not match the given rule. + + :param warnings: List of OntologyWarning objects. + :param rule: The rule to check against. + :return: True if any warning does not match the rule, False otherwise. + """ + return any(warning["rule"] != rule for warning in warnings) + + +def get_ontology_warnings( + config: NeedsSphinxConfig, + need: NeedsInfoType, + schema: NeedFieldsSchemaType, + fail_rule: MessageRuleEnum, + success_rule: MessageRuleEnum, + schema_path: list[str], + need_path: list[str], + user_message: str | None = None, +) -> list[OntologyWarning]: + warnings: list[OntologyWarning] = [] + warning: OntologyWarning + try: + validation_report = validate_local_need( + config=config, + need=need, + schema=schema, + ) + except TypeCoerceError as exc: + warning = { + "rule": MessageRuleEnum.extra_option_type_error, + "severity": get_severity(MessageRuleEnum.extra_option_type_error), + "validation_message": str(exc), + "need": need, + "schema_path": schema_path, + "need_path": need_path, + "field": exc.field, + } + if user_message is not None: + warning["user_message"] = user_message + warnings.append(warning) + return warnings + except ValidationError as exc: + warning = { + "rule": MessageRuleEnum.cfg_schema_error, + "severity": get_severity(MessageRuleEnum.cfg_schema_error), + "validation_message": str(exc), + "need": need, + "schema_path": schema_path, + "need_path": [need["id"]], + } + if user_message is not None: + warning["user_message"] = user_message + warnings.append(warning) + + if validation_report["messages"]: + for msg in validation_report["messages"]: + warning = { + "rule": fail_rule, + "severity": get_severity(fail_rule), + "validation_message": msg["message"], + "need": need, + "reduced_need": validation_report["reduced_need"], + "final_schema": validation_report["final_schema"], + "schema_path": [*schema_path, *msg["schema_path"]], + "need_path": need_path, + "field": msg["field"], + } + if user_message is not None: + warning["user_message"] = user_message + warnings.append(warning) + return warnings + else: + warning = { + "rule": success_rule, + "severity": get_severity(success_rule), + "need": need, + "reduced_need": validation_report["reduced_need"], + "final_schema": validation_report["final_schema"], + "schema_path": schema_path, + "need_path": need_path, + } + if user_message is not None: + warning["user_message"] = user_message + warnings.append(warning) + return warnings diff --git a/sphinx_needs/schema/process.py b/sphinx_needs/schema/process.py new file mode 100644 index 000000000..42a4bbc51 --- /dev/null +++ b/sphinx_needs/schema/process.py @@ -0,0 +1,94 @@ +import time +from pathlib import Path + +from sphinx.application import Sphinx +from sphinx.builders import Builder +from sphinx.util import logging + +from sphinx_needs.api import get_needs_view +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.needsfile import generate_needs_schema +from sphinx_needs.schema.config import SchemasRootType +from sphinx_needs.schema.core import ( + _needs_schema, + merge_static_schemas, + validate_need, +) +from sphinx_needs.schema.reporting import ( + OntologyWarning, + clear_debug_dir, + get_formatted_warnings, +) + +logger = logging.getLogger(__name__) + + +def process_schemas(app: Sphinx, builder: Builder) -> None: + """ + Validate all needs in a loop. + + Warnings and errors are emitted at the end. + """ + needs = get_needs_view(app) + config = NeedsSphinxConfig(app.config) + + # upfront work + any_static_found = merge_static_schemas(config) + + if not (any_static_found or (config.schema_definitions.get("schemas"))): + # nothing to validate + return + + # store the SN generated schema in a global variable + needs_schema = generate_needs_schema(config)["properties"] + _needs_schema.update(needs_schema) + + orig_debug_path = Path(config.schema_debug_path) + if not orig_debug_path.is_absolute(): + # make it relative to confdir + config.schema_debug_path = str((Path(app.confdir) / orig_debug_path).resolve()) + + if config.schema_debug_active: + clear_debug_dir(config) + + # Start timer before validation loop + start_time = time.perf_counter() + + need_2_warnings: dict[str, list[OntologyWarning]] = {} + + # validate needs + type_schemas: list[SchemasRootType] = [] + if config.schema_definitions and "schemas" in config.schema_definitions: + type_schemas = config.schema_definitions["schemas"] + for need in needs.values(): + nested_warnings = validate_need( + config=config, + need=need, + needs=needs, + type_schemas=type_schemas, + ) + if nested_warnings: + need_2_warnings[need["id"]] = nested_warnings + + # Stop timer after validation loop + end_time = time.perf_counter() + + formatted_warnings = get_formatted_warnings(need_2_warnings) + for warning in formatted_warnings: + if warning["log_lvl"] == "warning": + logger.warning( + warning["message"], type=warning["type"], subtype=warning["subtype"] + ) + elif warning["log_lvl"] == "error": + logger.error( + warning["message"], type=warning["type"], subtype=warning["subtype"] + ) + + duration = end_time - start_time + validated_needs_count = len(needs) + validated_rate = ( + round(validated_needs_count / duration) if duration > 0 else float("inf") + ) + logger.info( + f"Schema validation completed with {len(formatted_warnings)} warning(s) in {duration:.3f} seconds. Validated {validated_rate} needs/s." + ) diff --git a/sphinx_needs/schema/reporting.py b/sphinx_needs/schema/reporting.py new file mode 100644 index 000000000..f9f013d57 --- /dev/null +++ b/sphinx_needs/schema/reporting.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import NeedsInfoType +from sphinx_needs.schema.config import ( + MAP_RULE_DEFAULT_SEVERITY, + MessageRuleEnum, + NeedFieldsSchemaWithVersionType, + SeverityEnum, +) + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + +class OntologyWarning(TypedDict): + """ + Warning message for the ontology validation. + + These are final messages that are reported to the user. + The structure is a flattened version of the validation report ReportSchemaType. + """ + + rule: MessageRuleEnum + severity: SeverityEnum + user_message: NotRequired[str] + validation_message: NotRequired[str] + need: NeedsInfoType + reduced_need: NotRequired[dict[str, Any]] + final_schema: NotRequired[NeedFieldsSchemaWithVersionType] + schema_path: NotRequired[list[str]] + need_path: list[str] + field: NotRequired[str] + children: NotRequired[list[OntologyWarning]] + + +class ValidateNeedMessageType(TypedDict): + """Single validation message.""" + + field: str + """Affected need field.""" + message: str + """Validation message.""" + schema_path: list[str] + """Nested path in the single schema where the error occurred.""" + + +class ValidateNeedType(TypedDict): + """ + Return structure for validate_local_need. + + Specifically made for this as it misses the outer validation context. + """ + + messages: list[ValidateNeedMessageType] + """List of validation messages or empty if successful.""" + final_schema: NeedFieldsSchemaWithVersionType + """The assembled final schema that was used for validation.""" + reduced_need: dict[str, Any] + """The need as it was validated (reduced form).""" + + +_field_sep = "." +"""Separator between nested parts of field names in debug output file names.""" +_sep = "__" +"""Separator between nested parts of debug output file names.""" + + +def filter_warnings_severity( + config: NeedsSphinxConfig, + warnings: list[OntologyWarning], + schema_root_severity: SeverityEnum | None = None, +) -> list[OntologyWarning]: + """ + Filter warnings by severity. + + There are multiple severity sources: + - needs_config.schema_severity: the minimum severity for warnings to be reported + - needs_config.schema_definitions: contains a severity field that determines the reported rule severity + - schemas.config.MAP_RULE_DEFAULT_SEVERITY: default if the schemas severities field is not set + + Precedence for reporting: + - if the rule has a default severity of none, it is ignored; those are never of interest + as reporting target, only for debugging purposes + - if the schema root severity is set, it overrides the rule severity + - if the schema root severity is not set, the rule default severity is used + """ + min_severity_for_report = SeverityEnum[config.schema_severity] + filtered_warnings = [] + for warning in warnings: + rule_default_severity = MAP_RULE_DEFAULT_SEVERITY[warning["rule"]] + if rule_default_severity is SeverityEnum.none: + # rule is not of interest for final reporting (e.g. unselected needs) + # it might be logged for schema debugging however + continue + # the warning severity is overriden by the schema root severity + # if it is unset, the rule mapping MAP_RULE_DEFAULT_SEVERITY is used + warning_severity = ( + schema_root_severity + if schema_root_severity is not None + else warning["severity"] + ) + if warning_severity >= min_severity_for_report: + filtered_warnings.append(warning) + return filtered_warnings + + +def save_debug_files( + config: NeedsSphinxConfig, warnings: list[OntologyWarning] +) -> None: + """ + Write list of warnings as debug files. + + :param config: NeedsSphinxConfig object with debug settings. + :param warnings: List of OntologyWarning objects to save. + """ + if not config.schema_debug_active: + return + for warning in warnings: + save_debug_file(config, warning) + + +def save_debug_file( + config: NeedsSphinxConfig, + warning: OntologyWarning, +) -> None: + """Write debug json and schema files.""" + if not config.schema_debug_active: + return + if warning["rule"].name in config.schema_debug_ignore: + return + debug_dir = Path(config.schema_debug_path) + + filename = _field_sep.join(warning["need_path"]) + if schema_path := warning.get("schema_path"): + filename += _sep + _field_sep.join(schema_path) + filename += _sep + warning["rule"].name + + with (debug_dir / f"{filename}.json").open("w") as fp: + json.dump(warning["need"], fp, indent=2) + if reduced_need := warning.get("reduced_need"): + with (debug_dir / f"{filename}.reduced.json").open("w") as fp: + json.dump(reduced_need, fp, indent=2) + if final_schema := warning.get("final_schema"): + with (debug_dir / f"{filename}.schema.json").open("w") as fp: + json.dump(final_schema, fp, indent=2) + + messages = "" + if user_message := warning.get("user_message"): + messages += f"User message:\n{user_message}\n" + if validation_message := warning.get("validation_message"): + messages += f"Validation message:\n{validation_message}\n" + with (debug_dir / f"{filename}.txt").open("w") as fp: + fp.write(messages) + + +class FormattedWarning(TypedDict): + """Formatted warning message for the ontology validation.""" + + log_lvl: Literal["warning", "error"] + type: str + subtype: str + message: str + + +def get_formatted_warnings_recurse( + warning: OntologyWarning, level: int +) -> list[FormattedWarning]: + """ + Recursively format a warning message with indentation. + + :param warning: The OntologyWarning to format. + :return: A list of FormattedWarning objects. + """ + formatted_warnings: list[FormattedWarning] = [] + warning_msg = get_warning_msg(level, warning) + has_title = level == 0 + if warning["severity"] == SeverityEnum.config_error: + title = ( + f"Need '{warning['need']['id']}' has configuration errors:" + if has_title + else "" + ) + formatted_warning = FormattedWarning( + log_lvl="error", + type="sn_schema", + subtype=warning["rule"].value, + message=f"{title}{warning_msg}", + ) + else: + title = ( + f"Need '{warning['need']['id']}' has validation errors:" + if has_title + else "" + ) + formatted_warning = FormattedWarning( + log_lvl="warning", + type="sn_schema", + subtype=warning["rule"].value, + message=f"{title}{warning_msg}", + ) + formatted_warnings.append(formatted_warning) + if "children" in warning: + for child_warning in warning["children"]: + formatted_child_warnings = get_formatted_warnings_recurse( + warning=child_warning, level=level + 1 + ) + indent = " " * (1 + level + 1) + for formatted_child_warning in formatted_child_warnings: + formatted_warning["message"] += ( + f"\n\n{indent}Details for {child_warning['need']['id']}" + + formatted_child_warning["message"] + ) + # formatted_warnings.extend(formatted_child_warnings) + return formatted_warnings + + +def get_formatted_warnings( + need_2_warnings: dict[str, list[OntologyWarning]], +) -> list[FormattedWarning]: + """ + Pretty format warnings from the ontology validation. + + :returns: tuple [log level, type, sub-type, message] + """ + formatted_warnings: list[FormattedWarning] = [] + for warnings in need_2_warnings.values(): + for warning in warnings: + new_warnings = get_formatted_warnings_recurse(warning=warning, level=0) + formatted_warnings.extend(new_warnings) + return formatted_warnings + + +def get_warning_msg(base_lvl: int, warning: OntologyWarning) -> str: + """Craft a properly indented warning message.""" + warning_msg = "" + + lvl_severity = 1 + lvl_field = 1 + lvl_need_path = 1 + lvl_schema_path = 1 + lvl_validation_msg = 1 + lvl_user_msg = 1 + + def nl_indent(level: int) -> str: + return "\n" + (base_lvl + level) * 2 * " " + + if base_lvl == 0: + # top level warning already reports the severity + warning_msg += ( + nl_indent(lvl_severity) + f"Severity: {warning['severity'].name}" + ) + if "field" in warning: + warning_msg += nl_indent(lvl_field) + f"Field: {warning['field']}" + if warning["need_path"]: + need_path_str = " > ".join(warning["need_path"]) + warning_msg += nl_indent(lvl_need_path) + f"Need path: {need_path_str}" + if "schema_path" in warning: + schema_path_str = " > ".join(warning["schema_path"]) + warning_msg += nl_indent(lvl_schema_path) + f"Schema path: {schema_path_str}" + if "user_message" in warning: + warning_msg += ( + nl_indent(lvl_user_msg) + f"User message: {warning['user_message']}" + ) + if "validation_message" in warning: + warning_msg += ( + nl_indent(lvl_validation_msg) + + f"Schema message: {warning['validation_message']}" + ) + + return warning_msg + + +def clear_debug_dir(config: NeedsSphinxConfig) -> None: + debug_path = Path(config.schema_debug_path) + if debug_path.exists(): + for file in debug_path.glob("*"): + file.unlink() + else: + debug_path.mkdir() diff --git a/sphinx_needs/schema/utils.py b/sphinx_needs/schema/utils.py new file mode 100644 index 000000000..45a49e14f --- /dev/null +++ b/sphinx_needs/schema/utils.py @@ -0,0 +1,25 @@ +from sphinx_needs.schema.config import NeedFieldsSchemaWithVersionType + + +def get_properties_from_schema( + schema: NeedFieldsSchemaWithVersionType, +) -> set[str]: + """ + Extract a list of property names from a given JSON schema. + + It searches both the top-level "properties" and any nested properties in "allOf" schemas. + """ + properties: set[str] = set() + + # Extract properties in the main "properties" field + if "properties" in schema and isinstance(schema["properties"], dict): + properties.update(schema["properties"].keys()) + + # If there is an "allOf" key with additional schemas, extract their properties as well + if "allOf" in schema and isinstance(schema["allOf"], list): + for subschema in schema["allOf"]: + assert "$ref" not in subschema, "$ref have already been resolved" + if "properties" in subschema and isinstance(subschema["properties"], dict): + properties.update(subschema["properties"].keys()) + + return properties diff --git a/tests/benchmarks/__snapshots__sphinx_ge_8/test_schema_benchmark.ambr b/tests/benchmarks/__snapshots__sphinx_ge_8/test_schema_benchmark.ambr new file mode 100644 index 000000000..8a3a23c18 --- /dev/null +++ b/tests/benchmarks/__snapshots__sphinx_ge_8/test_schema_benchmark.ambr @@ -0,0 +1,724 @@ +# serializer version: 1 +# name: test_schema_benchmark[100] + list([ + ''' + Need 'FEAt_P01' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P01 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P01' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P01' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P01' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P01 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P01' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P01' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P01' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P01' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P01 > links > SPEC_SAFE_UNSAFE_FEAT_P01 > links > FEAT_P01 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P02' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P02 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P02' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P02' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P02' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P02 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P02' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P02' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P02' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P02' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P02 > links > SPEC_SAFE_UNSAFE_FEAT_P02 > links > FEAT_P02 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P03' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P03 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P03' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P03' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P03' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P03 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P03' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P03' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P03' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P03' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P03 > links > SPEC_SAFE_UNSAFE_FEAT_P03 > links > FEAT_P03 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P04' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P04 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P04' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P04' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P04' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P04 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P04' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P04' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P04' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P04' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P04 > links > SPEC_SAFE_UNSAFE_FEAT_P04 > links > FEAT_P04 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P05' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P05 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P05' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P05' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P05' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P05 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P05' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P05' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P05' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P05' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P05 > links > SPEC_SAFE_UNSAFE_FEAT_P05 > links > FEAT_P05 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P06' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P06 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P06' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P06' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P06' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P06 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P06' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P06' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P06' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P06' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P06 > links > SPEC_SAFE_UNSAFE_FEAT_P06 > links > FEAT_P06 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P07' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P07 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P07' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P07' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P07' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P07 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P07' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P07' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P07' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P07' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P07 > links > SPEC_SAFE_UNSAFE_FEAT_P07 > links > FEAT_P07 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P08' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P08 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P08' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P08' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P08' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P08 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P08' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P08' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P08' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P08' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P08 > links > SPEC_SAFE_UNSAFE_FEAT_P08 > links > FEAT_P08 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P09' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P09 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P09' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P09' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P09' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P09 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P09' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P09' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P09' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P09' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P09 > links > SPEC_SAFE_UNSAFE_FEAT_P09 > links > FEAT_P09 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ''' + Need 'FEAt_P10' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P10 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P10' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P10' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P10' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P10 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P10' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P10' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P10' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P10' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P10 > links > SPEC_SAFE_UNSAFE_FEAT_P10 > links > FEAT_P10 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ]) +# --- +# name: test_schema_benchmark[10] + list([ + ''' + Need 'FEAt_P1' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P1 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P1' does not match '^[A-Z0-9_]+$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P1' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P1' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P1 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P1' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P1' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'SPEC_SAFE_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P1' does not match '^REQ[a-zA-Z0-9_-]*$' [sn_schema.local_fail] + + ''', + ''' + Need 'FEAT_P1' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P1 > links > SPEC_SAFE_UNSAFE_FEAT_P1 > links > FEAT_P1 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] [sn_schema.network_local_fail] + + ''', + ]) +# --- diff --git a/tests/benchmarks/__snapshots__sphinx_lt_8/test_schema_benchmark.ambr b/tests/benchmarks/__snapshots__sphinx_lt_8/test_schema_benchmark.ambr new file mode 100644 index 000000000..9ccb53530 --- /dev/null +++ b/tests/benchmarks/__snapshots__sphinx_lt_8/test_schema_benchmark.ambr @@ -0,0 +1,724 @@ +# serializer version: 1 +# name: test_schema_benchmark[100] + list([ + ''' + Need 'FEAt_P01' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P01 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P01' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P01' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P01' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P01 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P01' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P01' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P01' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P01 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P01' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P01' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P01 > links > SPEC_SAFE_UNSAFE_FEAT_P01 > links > FEAT_P01 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P02' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P02 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P02' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P02' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P02' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P02 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P02' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P02' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P02' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P02 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P02' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P02' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P02 > links > SPEC_SAFE_UNSAFE_FEAT_P02 > links > FEAT_P02 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P03' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P03 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P03' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P03' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P03' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P03 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P03' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P03' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P03' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P03 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P03' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P03' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P03 > links > SPEC_SAFE_UNSAFE_FEAT_P03 > links > FEAT_P03 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P04' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P04 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P04' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P04' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P04' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P04 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P04' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P04' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P04' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P04 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P04' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P04' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P04 > links > SPEC_SAFE_UNSAFE_FEAT_P04 > links > FEAT_P04 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P05' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P05 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P05' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P05' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P05' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P05 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P05' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P05' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P05' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P05 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P05' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P05' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P05 > links > SPEC_SAFE_UNSAFE_FEAT_P05 > links > FEAT_P05 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P06' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P06 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P06' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P06' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P06' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P06 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P06' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P06' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P06' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P06 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P06' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P06' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P06 > links > SPEC_SAFE_UNSAFE_FEAT_P06 > links > FEAT_P06 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P07' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P07 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P07' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P07' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P07' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P07 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P07' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P07' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P07' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P07 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P07' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P07' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P07 > links > SPEC_SAFE_UNSAFE_FEAT_P07 > links > FEAT_P07 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P08' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P08 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P08' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P08' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P08' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P08 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P08' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P08' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P08' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P08 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P08' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P08' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P08 > links > SPEC_SAFE_UNSAFE_FEAT_P08 > links > FEAT_P08 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P09' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P09 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P09' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P09' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P09' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P09 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P09' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P09' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P09' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P09 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P09' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P09' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P09 > links > SPEC_SAFE_UNSAFE_FEAT_P09 > links > FEAT_P09 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ''' + Need 'FEAt_P10' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P10 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P10' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P10' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P10' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P10 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P10' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P10' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P10' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P10 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P10' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P10' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P10 > links > SPEC_SAFE_UNSAFE_FEAT_P10 > links > FEAT_P10 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ]) +# --- +# name: test_schema_benchmark[10] + list([ + ''' + Need 'FEAt_P1' has validation errors: + Severity: violation + Field: id + Need path: FEAt_P1 + Schema path: [0] > local > properties > id > pattern + User message: id must be uppercase + Schema message: 'FEAt_P1' does not match '^[A-Z0-9_]+$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_MISSING_APPROVAL_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_MISSING_APPROVAL_P1' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_MISSING_APPROVAL_P1' has validation errors: + Severity: violation + Field: + Need path: SPEC_MISSING_APPROVAL_P1 + Schema path: spec-approved[2] > local > required + User message: Approval required due to high efforts + Schema message: 'approved' is a required property + + ''', + ''' + Need 'SPEC_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_P1' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_UNSAFE_FEAT_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_UNSAFE_FEAT_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_UNSAFE_FEAT_P1' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'SPEC_SAFE_P1' has validation errors: + Severity: violation + Field: id + Need path: SPEC_SAFE_P1 + Schema path: spec[1] > local > properties > id > pattern + Schema message: 'SPEC_SAFE_P1' does not match '^REQ[a-zA-Z0-9_-]*$' + + ''', + ''' + Need 'FEAT_P1' has validation errors: + Severity: violation + Field: asil + Need path: IMPL_SAFE_P1 > links > SPEC_SAFE_UNSAFE_FEAT_P1 > links > FEAT_P1 + Schema path: safe-impl-[links]->safe-spec-[links]->safe-feat[4] > links > links > local > allOf > 0 > allOf > 0 > properties > asil > enum + Schema message: 'QM' is not one of ['A', 'B', 'C', 'D'] + + ''', + ]) +# --- diff --git a/tests/benchmarks/test_schema_benchmark.py b/tests/benchmarks/test_schema_benchmark.py new file mode 100644 index 000000000..499b829b9 --- /dev/null +++ b/tests/benchmarks/test_schema_benchmark.py @@ -0,0 +1,120 @@ +from collections.abc import Callable +from pathlib import Path +from textwrap import dedent + +import pytest +import sphinx +import syrupy +from jinja2 import Template +from sphinx.testing.util import SphinxTestApp +from syrupy.extensions.amber import AmberSnapshotExtension +from syrupy.location import PyTestLocation + +# split snapshot dir as warning (sub-)types are emitted only for Sphinx >= 8 +SNAPSHOT_DIR = ( + "__snapshots__sphinx_lt_8" + if sphinx.version_info[0] < 8 + else "__snapshots__sphinx_ge_8" +) +"""Snapshot directory name.""" + + +class DifferentDirectoryExtension(AmberSnapshotExtension): + """Overwrite the directory name for the snapshot files.""" + + @classmethod + def dirname(cls, *, test_location: "PyTestLocation") -> str: + return str(Path(test_location.filepath).parent.joinpath(SNAPSHOT_DIR)) + + +@pytest.fixture +def snapshot(snapshot: syrupy.SnapshotAssertion): + return snapshot.use_extension(DifferentDirectoryExtension) + + +@pytest.mark.parametrize( + "need_cnt", + [ + 10, + 100, + 1_000, + # 5_000, + # 10_000, + ], +) +@pytest.mark.benchmark +def test_schema_benchmark( + tmpdir: Path, + make_app: Callable[[], SphinxTestApp], + need_cnt: int, + snapshot: syrupy.SnapshotAssertion, + get_warnings_list: Callable[[SphinxTestApp], list[str]], +): + """ + Benchmark on many needs with thousands of warnings. + + This also tests the dedicated schema builder. + """ + assert need_cnt % 10 == 0, "need_cnt must be a multiple of 10" + page_cnt = int(need_cnt / 10) + + this_file_dir = Path(__file__).parent + + src_dir = this_file_dir / ".." / "doc_test" / "doc_schema_benchmark" + page_template_path = src_dir / "page.rst.j2" + with page_template_path.open() as fp: + template_content = fp.read() + + template = Template(template_content) + pages_dir = Path(tmpdir) / "pages" + pages_dir.mkdir(exist_ok=True) + toctree_content = dedent( + """ + .. toctree:: + :maxdepth: 2 + + """ + ) + + width = len(str(page_cnt)) + for i in range(1, page_cnt + 1): + i_fmt = f"{i:0{width}d}" + page_rst_content = template.render(page_nr=i_fmt) + + page_name = f"page_{i_fmt}" + page_file = f"{page_name}.rst" + page_rst_path = pages_dir / page_file + page_rst_path.write_text(page_rst_content, encoding="utf-8") + toctree_content += f" pages/{page_name}\n" + + index_file = tmpdir / "index.rst" + index_file.write_text(toctree_content, encoding="utf-8") + + copy_files = [ + src_dir / "conf.py", + src_dir / "schemas.json", + src_dir / "ubproject.toml", + ] + for copy_file in copy_files: + dst_file = tmpdir / copy_file.name + dst_file.write_text(copy_file.read_text(), encoding="utf-8") + + app: SphinxTestApp = make_app( + # the schema builder does only validate, no output + buildername="schema", + srcdir=Path(tmpdir), + freshenv=True, + ) + app.build() + + assert app.statuscode == 0 + + warnings = get_warnings_list(app) + expected_warnings_per_page = 7 + assert len(warnings) == expected_warnings_per_page * page_cnt + + if need_cnt < 500: + # keep snapshot small; + assert warnings == snapshot + + app.cleanup() diff --git a/tests/conftest.py b/tests/conftest.py index a3108410a..7a33a7878 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ """Pytest conftest module containing common test configuration and fixtures.""" +from __future__ import annotations + import json import os.path import secrets @@ -10,9 +12,12 @@ import sys import tempfile from pathlib import Path -from typing import Any +from typing import Any, Literal import pytest +import sphinx +import yaml +from _pytest.mark import ParameterSet from docutils.nodes import document from sphinx import version_info from sphinx.application import Sphinx @@ -359,3 +364,139 @@ def snapshot_doctree(snapshot): except AttributeError: # fallback for older versions of pytest-snapshot return snapshot.use_extension(DoctreeSnapshotExtension) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Generate tests for a ``@pytest.mark.fixture_file`` decorator.""" + for marker in metafunc.definition.iter_markers(name="fixture_file"): + params = create_parameters(*marker.args, **marker.kwargs) + metafunc.parametrize(argnames="content", argvalues=params) + + +THIS_DIR = Path(__file__).parent + + +def create_parameters( + *rel_paths: str, skip_files: None | list[str] = None +) -> list[ParameterSet]: + """Create parameters for a pytest param_file decorator.""" + paths: list[Path] = [] + for rel_path in rel_paths: + assert not Path(rel_path).is_absolute() + path = THIS_DIR.joinpath(rel_path) + if path.is_file(): + paths.append(path) + elif path.is_dir(): + paths.extend(path.glob("*.yaml")) + else: + raise FileNotFoundError(f"File / folder not found: {path}") + + if skip_files: + paths = [ + path for path in paths if str(path.relative_to(THIS_DIR)) not in skip_files + ] + + if not paths: + raise FileNotFoundError(f"No files found: {rel_paths}") + + if len(paths) == 1: + with paths[0].open(encoding="utf8") as f: + try: + data = yaml.safe_load(f) + except Exception as err: + raise OSError(f"Error loading {paths[0]}") from err + return [pytest.param(value, id=id) for id, value in data.items()] + else: + params: list[ParameterSet] = [] + for subpath in paths: + with subpath.open(encoding="utf8") as f: + try: + data = yaml.safe_load(f) + except Exception as err: + raise OSError(f"Error loading {subpath}") from err + for key, value in data.items(): + params.append( + pytest.param( + value, + id=f"{subpath.relative_to(THIS_DIR).with_suffix('')}-{key}", + ) + ) + return params + + +@pytest.fixture +def write_fixture_files(): + def _inner(tmp: Path, content: dict[str, str]) -> None: + section_file_mapping: dict[str, Path] = { + "conf": tmp / "conf.py", + "ubproject": tmp / "ubproject.toml", + "rst": tmp / "index.rst", + "schemas": tmp / "schemas.json", + } + for section, file_path in section_file_mapping.items(): + if section in content: + if isinstance(content[section], dict): + # used for schemas.json + file_path.write_text( + json.dumps(content[section], indent=2), encoding="utf-8" + ) + elif isinstance(content[section], str): + file_path.write_text(content[section], encoding="utf-8") + else: + raise ValueError( + f"Unsupported content type for section '{section}': {type(content[section])}" + ) + + return _inner + + +@pytest.fixture +def check_ontology_warnings(): + def _inner( + app: SphinxTestApp, + expected_warnings: list[list[str | dict[Literal["sphinx8"], list[str]]]], + ) -> None: + warnings_raw = strip_colors(app.warning.getvalue()) + warnings = [part for part in warnings_raw.split("WARNING: ") if part] + for expected_warning in expected_warnings: + for search_item in expected_warning: + if isinstance(search_item, dict): + # Handle the case where we have a dictionary with sphinx8 key + assert "sphinx8" in search_item, ( + f"Expected 'sphinx8' key in warning: {search_item}" + ) + if sphinx.version_info[0] < 8: + continue + expected_split = search_item["sphinx8"] + else: + expected_split = search_item.split(" # ") + # all of the entries in expected_split must be in the warnings on a single line + assert any( + all(part in warning for part in expected_split) + for warning in warnings + ), ( + f"Expected warning '{expected_split}' not found in warnings: {warnings}" + ) + + assert len(warnings) == len(expected_warnings), ( + f"Unexpected warnings found: {warnings}" + ) + + return _inner + + +@pytest.fixture +def get_warnings_list(): + """ + Fixture to get a list of warnings from a SphinxTestApp. + + The split happens in each occurence of "WARNING: ". + Each warning is returned as a string with \n as multi line speparator. + """ + + def _get_warnings_list(app: SphinxTestApp) -> list[str]: + warnings_raw = strip_colors(app.warning.getvalue()) + warnings_split = [part for part in warnings_raw.split("WARNING: ") if part] + return warnings_split + + return _get_warnings_list diff --git a/tests/doc_test/doc_schema_benchmark/conf.py b/tests/doc_test/doc_schema_benchmark/conf.py new file mode 100644 index 000000000..081aad3e5 --- /dev/null +++ b/tests/doc_test/doc_schema_benchmark/conf.py @@ -0,0 +1,12 @@ +project = "basic test" + +extensions = ["sphinx_needs"] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +needs_from_toml = "ubproject.toml" +needs_schema_definitions_from_json = "schemas.json" + +html_theme = "alabaster" +suppress_warnings = ["needs.beta"] diff --git a/tests/doc_test/doc_schema_benchmark/page.rst.j2 b/tests/doc_test/doc_schema_benchmark/page.rst.j2 new file mode 100644 index 000000000..85db67834 --- /dev/null +++ b/tests/doc_test/doc_schema_benchmark/page.rst.j2 @@ -0,0 +1,56 @@ +basic test +========== + +.. feat:: feat wrong id + :id: FEAt_P{{ page_nr }} + :asil: QM + +.. feat:: feat + :id: FEAT_P{{ page_nr }} + :asil: QM + +.. feat:: feat + :id: FEAT_SAFE_P{{ page_nr }} + :asil: C + +.. feat:: feat + :id: FEAT_SAFE2_P{{ page_nr }} + :asil: D + +.. spec:: spec missing approval + :id: SPEC_MISSING_APPROVAL_P{{ page_nr }} + :efforts: 20 + :priority: 1 + :asil: QM + +.. spec:: spec + :id: SPEC_P{{ page_nr }} + :efforts: 10 + :priority: 1 + :asil: QM + +.. spec:: safe spec links unsafe feat + :id: SPEC_SAFE_UNSAFE_FEAT_P{{ page_nr }} + :efforts: 20 + :priority: 1 + :asil: B + :links: FEAT_P{{ page_nr }}, FEAT_SAFE2_P{{ page_nr }} + :approved: yes + +.. spec:: safe spec + :id: SPEC_SAFE_P{{ page_nr }} + :efforts: 20 + :priority: 1 + :asil: B + :links: FEAT_SAFE_P{{ page_nr }}, FEAT_SAFE2_P{{ page_nr }} + :approved: yes + +.. impl:: impl + :id: IMPL_P{{ page_nr }} + :efforts: 30 + :links: SPEC_P{{ page_nr }} + +.. impl:: safe impl + :id: IMPL_SAFE_P{{ page_nr }} + :asil: A + :links: SPEC_SAFE_UNSAFE_FEAT_P{{ page_nr }} diff --git a/tests/doc_test/doc_schema_benchmark/schemas.json b/tests/doc_test/doc_schema_benchmark/schemas.json new file mode 100644 index 000000000..12b06bfb0 --- /dev/null +++ b/tests/doc_test/doc_schema_benchmark/schemas.json @@ -0,0 +1,148 @@ +{ + "$defs": { + "type-feat": { + "properties": { + "type": { "const": "feat" } + } + }, + "type-spec": { + "properties": { + "type": { "const": "spec" } + } + }, + "type-impl": { + "properties": { + "type": { "const": "impl" } + } + }, + "safe-feat": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-feat" } + ] + }, + "safe-spec": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-spec" } + ] + }, + "safe-impl": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-impl" } + ] + }, + "safe-need": { + "properties": { + "asil": { "enum": ["A", "B", "C", "D"] } + }, + "required": ["asil"] + }, + "high-efforts": { + "properties": { + "efforts": { "minimum": 15 } + }, + "required": ["efforts"] + } + }, + "schemas": [ + { + "severity": "warning", + "message": "id must be uppercase", + "validate": { + "local": { + "properties": { + "id": { "pattern": "^[A-Z0-9_]+$" } + } + } + } + }, + { + "id": "spec", + "select": { + "$ref": "#/$defs/type-spec" + }, + "validate": { + "local": { + "properties": { + "id": { "pattern": "^REQ[a-zA-Z0-9_-]*$" }, + "efforts": { "minimum": 0 } + }, + "unevaluatedProperties": true + } + } + }, + { + "id": "spec-approved", + "severity": "info", + "message": "Approval required due to high efforts", + "select": { + "allOf": [ + { "$ref": "#/$defs/type-spec" }, + { "$ref": "#/$defs/high-efforts" } + ] + }, + "validate": { + "local": { + "properties": { + "approved": { "const": true } + }, + "required": ["approved"] + } + } + }, + { + "id": "safe-spec-[links]->safe-feat", + "select": { + "$ref": "#/$defs/safe-spec" + }, + "validate": { + "network": { + "links": { + "contains": { + "local": { + "$ref": "#/$defs/safe-feat" + } + }, + "minContains": 1, + "maxContains": 4 + } + } + } + }, + { + "id": "safe-impl-[links]->safe-spec-[links]->safe-feat", + "message": "Safe impl links to safe spec to safe feat", + "select": { + "$ref": "#/$defs/safe-impl" + }, + "validate": { + "network": { + "links": { + "items": { + "local": { + "properties": { + "links": { "minItems": 1 } + }, + "allOf": [{ "$ref": "#/$defs/safe-spec" }] + }, + "network": { + "links": { + "items": { + "local": { + "properties": { + "links": { "minItems": 1 } + }, + "allOf": [{ "$ref": "#/$defs/safe-feat" }] + } + } + } + } + } + } + } + } + } + ] +} diff --git a/tests/doc_test/doc_schema_benchmark/ubproject.toml b/tests/doc_test/doc_schema_benchmark/ubproject.toml new file mode 100644 index 000000000..116b18dd7 --- /dev/null +++ b/tests/doc_test/doc_schema_benchmark/ubproject.toml @@ -0,0 +1,55 @@ +"$schema" = "https://ubcode.useblocks.com/ubproject.schema.json" + +[needs] +id_required = true +a = ["a", 3, { "a" = "b" }] +id_regex = "^[A-Z0-9_]{3,}" +schema_severity = 'info' + +[[needs.extra_options]] +name = "efforts" +description = "FTE days" +schema.type = "integer" +schema.minimum = 0 + +[[needs.extra_options]] +name = "priority" +description = "Priority level, 1-5 where 1 is highest and 5 is lowest" +schema.type = "integer" +schema.minimum = 1 +schema.maximum = 5 + +[[needs.extra_options]] +name = "asil" +description = "Automotive Safety Integrity Level" +schema.type = "string" +schema.enum = ["QM", "A", "B", "C", "D"] + +[[needs.extra_options]] +name = "approved" +description = "Approval flag" +schema.type = "boolean" + +[[needs.extra_links]] +option = "links" +outgoing = "links" +incoming = "linked by" +schema.type = "array" +schema.items = { type = "string", pattern = "^[A-Z0-9_]{3,}" } + +[[needs.types]] +directive = "feat" +title = "Feat" +prefix = "Feat_" + +[[needs.types]] +directive = "spec" +title = "Specification" +prefix = "SPEC_" + +[[needs.types]] +directive = "impl" +title = "Implementation" +prefix = "IMPL_" + +# link modeling impl -[links]-> spec -[links]-> feat diff --git a/tests/doc_test/doc_schema_e2e/conf.py b/tests/doc_test/doc_schema_e2e/conf.py new file mode 100644 index 000000000..bfcd8e792 --- /dev/null +++ b/tests/doc_test/doc_schema_e2e/conf.py @@ -0,0 +1,15 @@ +project = "basic test" +# copyright = "2024, basic test" +# author = "basic test" + +extensions = ["sphinx_needs"] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +needs_from_toml = "ubproject.toml" +needs_schema_definitions_from_json = "schemas.json" + +html_theme = "alabaster" + +suppress_warnings = ["needs.beta"] diff --git a/tests/doc_test/doc_schema_e2e/index.rst b/tests/doc_test/doc_schema_e2e/index.rst new file mode 100644 index 000000000..a2992480b --- /dev/null +++ b/tests/doc_test/doc_schema_e2e/index.rst @@ -0,0 +1,65 @@ + +basic test +========== + +.. feat:: feat wrong id + :id: FEAt + :asil: QM + +.. feat:: feat + :id: FEAT + :asil: QM + +.. feat:: feat safe + :id: FEAT_SAFE + :asil: C + +.. feat:: feat safe 2 + :id: FEAT_SAFE2 + :asil: D + +.. spec:: spec missing approval + :id: SPEC_MISSING_APPROVAL + :efforts: 20 + :priority: 1 + :asil: QM + +.. spec:: spec + :id: SPEC + :efforts: 10 + :priority: 1 + :asil: QM + +.. spec:: safe spec links unsafe feat + :id: SPEC_SAFE_UNSAFE_FEAT + :efforts: 20 + :priority: 1 + :asil: B + :links: FEAT + :approved: yes + +.. spec:: safe spec additional items + :id: SPEC_SAFE_ADD_UNSAFE_FEAT + :efforts: 20 + :priority: 1 + :asil: B + :links: FEAT, FEAT_SAFE2 + :approved: yes + +.. spec:: safe spec + :id: SPEC_SAFE + :efforts: 20 + :priority: 1 + :asil: B + :links: FEAT_SAFE, FEAT_SAFE2 + :approved: yes + +.. impl:: impl + :id: IMPL + :efforts: 30 + :links: SPEC + +.. impl:: safe impl + :id: IMPL_SAFE + :asil: A + :links: SPEC_SAFE_UNSAFE_FEAT diff --git a/tests/doc_test/doc_schema_e2e/schemas.json b/tests/doc_test/doc_schema_e2e/schemas.json new file mode 100644 index 000000000..2723540a4 --- /dev/null +++ b/tests/doc_test/doc_schema_e2e/schemas.json @@ -0,0 +1,146 @@ +{ + "$defs": { + "type-feat": { + "properties": { + "type": { "const": "feat" } + } + }, + "type-spec": { + "properties": { + "type": { "const": "spec" } + } + }, + "type-impl": { + "properties": { + "type": { "const": "impl" } + } + }, + "safe-feat": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-feat" } + ] + }, + "safe-spec": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-spec" } + ] + }, + "safe-impl": { + "allOf": [ + { "$ref": "#/$defs/safe-need" }, + { "$ref": "#/$defs/type-impl" } + ] + }, + "safe-need": { + "properties": { + "asil": { "enum": ["A", "B", "C", "D"] } + }, + "required": ["asil"] + }, + "high-efforts": { + "properties": { + "efforts": { "minimum": 15 } + }, + "required": ["efforts"] + } + }, + "schemas": [ + { + "severity": "warning", + "message": "id must be uppercase with numbers and underscores", + "validate": { + "local": { "properties": { "id": { "pattern": "^[A-Z0-9_]+$" } } } + } + }, + { + "id": "spec", + "select": { + "$ref": "#/$defs/type-spec" + }, + "validate": { + "local": { + "properties": { + "id": { "pattern": "^SPEC_[a-zA-Z0-9_-]*$" }, + "efforts": { "minimum": 0 } + }, + "unevaluatedProperties": false + } + } + }, + { + "id": "spec-approved-required", + "severity": "violation", + "message": "Approval required due to high efforts", + "select": { + "allOf": [ + { "$ref": "#/$defs/high-efforts" }, + { "$ref": "#/$defs/type-spec" } + ] + }, + "validate": { + "local": { + "required": ["approved"] + } + } + }, + { + "id": "spec-approved-not-given", + "severity": "info", + "message": "Approval not given", + "select": { + "allOf": [ + { "$ref": "#/$defs/type-spec" }, + { "$ref": "#/$defs/high-efforts" } + ] + }, + "validate": { + "local": { + "properties": { + "approved": { "const": true } + }, + "required": ["approved"] + } + } + }, + { + "id": "safe-spec-[links]->safe-feat", + "message": "Safe spec links to safe feat", + "select": { + "$ref": "#/$defs/safe-spec" + }, + "validate": { + "network": { + "links": { + "contains": { + "local": { + "$ref": "#/$defs/safe-feat" + } + }, + "minContains": 1, + "maxContains": 4 + } + } + } + }, + { + "id": "safe-impl-[links]->safe-spec", + "message": "Safe impl links to safe spec", + "select": { + "$ref": "#/$defs/safe-impl" + }, + "validate": { + "network": { + "links": { + "items": { + "local": { + "$ref": "#/$defs/safe-spec" + } + } + } + } + } + } + ] +} diff --git a/tests/doc_test/doc_schema_e2e/ubproject.toml b/tests/doc_test/doc_schema_e2e/ubproject.toml new file mode 100644 index 000000000..9f71eaa92 --- /dev/null +++ b/tests/doc_test/doc_schema_e2e/ubproject.toml @@ -0,0 +1,63 @@ +"$schema" = "https://ubcode.useblocks.com/ubproject.schema.json" + +[needs] +id_required = true +id_regex = "^[A-Z0-9_]{3,}" +schema_severity = 'warning' + +[[needs.extra_options]] +name = "string_option_wo_schema" +description = "String option" + +[[needs.extra_options]] +name = "string_option_w_empty_schema" +description = "String option" +schema = {} + +[[needs.extra_options]] +name = "efforts" +description = "FTE days" +schema.type = "integer" +schema.minimum = 0 + +[[needs.extra_options]] +name = "priority" +description = "Priority level, 1-5 where 1 is highest and 5 is lowest" +schema.type = "integer" +schema.minimum = 1 +schema.maximum = 5 + +[[needs.extra_options]] +name = "asil" +description = "Automotive Safety Integrity Level" +schema.type = "string" +schema.enum = ["QM", "A", "B", "C", "D"] + +[[needs.extra_options]] +name = "approved" +description = "Approval flag" +schema.type = "boolean" + +[[needs.extra_links]] +option = "links" +outgoing = "links" +incoming = "linked by" +schema.type = "array" +schema.items = { type = "string", pattern = "^[A-Z0-9_]{3,}" } + +[[needs.types]] +directive = "feat" +title = "Feat" +prefix = "Feat_" + +[[needs.types]] +directive = "spec" +title = "Specification" +prefix = "SPEC_" + +[[needs.types]] +directive = "impl" +title = "Implementation" +prefix = "IMPL_" + +# link modeling impl -[links]-> spec -[links]-> feat diff --git a/tests/schema/fixtures/config.yml b/tests/schema/fixtures/config.yml new file mode 100644 index 000000000..b53950ef0 --- /dev/null +++ b/tests/schema/fixtures/config.yml @@ -0,0 +1,572 @@ +extra_link_array_wrong_type: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.type = "object" + schema.minItems = 2 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + exception: + - Schema for extra link 'links' is not valid + - value of key 'type' of dict is not any of ('array') + +missing_type_select: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + ubproject: | + [[needs.extra_options]] + name = "efforts" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 12 + schemas: + schemas: + - select: + properties: + efforts: {minimum: 15} + validate: + local: + required: [efforts] + exception: + - "Schemas entry '[0]' is not valid" + - "value of key 'select' of dict did not match any element in the union" + - 'sphinx_needs.schema.config.ExtraOptionStringSchemaType: has unexpected extra key(s): "minimum"' + - "sphinx_needs.schema.config.ExtraOptionIntegerSchemaType: value of key 'type' is not any of ('integer')" + +missing_type_validate_local: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + ubproject: | + [[needs.extra_options]] + name = "efforts" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 12 + schemas: + schemas: + - validate: + local: + properties: + efforts: {minimum: 15} + exception: + - "Schemas entry '[0]' is not valid" + - "value of key 'local' of value of key 'validate' of dict did not match any element in the union" + - 'sphinx_needs.schema.config.ExtraOptionStringSchemaType: has unexpected extra key(s): "minimum"' + - "sphinx_needs.schema.config.ExtraOptionIntegerSchemaType: value of key 'type' is not any of ('integer')" + +type_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + ubproject: | + [[needs.extra_options]] + name = "efforts" + schema.type = "unknown" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 12 + exception: + - "Schema for extra option 'efforts' has invalid type: unknown. Allowed types are: string, boolean, integer, number, array" + +select_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + ubproject: | + [[needs.extra_options]] + name = "efforts" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 12 + schemas: + schemas: + - select: + properties: + efforts: {type: "unknown"} + exception: + - "Config error in schema '[0]': Field 'efforts' has type 'unknown', but expected 'string'." + +both_definitions_and_json_given: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions = { + "schemas": [ + { + "select": { + "properties": { + "efforts": {"minimum": 15} + } + }, + "validate": { + "local": { + "required": ["efforts"] + } + } + } + ] + } + needs_schema_definitions_from_json = "schemas.json" + ubproject: | + [[needs.extra_options]] + name = "efforts" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 12 + schemas: + schemas: + - select: + properties: + efforts: {minimum: 15} + validate: + local: + required: [efforts] + exception: + - "You cannot use both 'needs_schema_definitions' and 'needs_schema_definitions_from_json' at the same time" + +local_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + ubproject: | + [[needs.extra_options]] + name = "efforts" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 12 + schemas: + schemas: + - validate: + local: + properties: + efforts: {type: "unknown"} + exception: + - "Config error in schema '[0]': Field 'efforts' has type 'unknown', but expected 'string'." + +network_link_not_exist: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + network: + links2: + minContains: 2 + exception: + - "Schema '[0]' defines an unknown network link type 'links2'" + +ref_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + $defs: + type-impl: + properties: + type: + const: impl + schemas: + - validate: + local: + $ref: "#/$defs/not-exist" + exception: + - "Reference 'not-exist' not found in definitions" + +ref_mixed_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + $defs: + type-impl: + properties: + type: + const: impl + schemas: + - validate: + local: + $ref: "#/$defs/not-exist" + required: [type] + exception: + - "Invalid $ref entry, expected a single $ref key" + - "{'$ref': '#/$defs/not-exist', 'required': ['type']}" + +ref_recursive: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + $defs: + type-impl: + properties: + type: + const: impl + allOf: + - $ref: "#/$defs/type-impl2" + type-impl2: + $ref: "#/$defs/type-impl" + + schemas: + - validate: + local: + $ref: "#/$defs/type-impl" + exception: + - "Circular reference detected for 'type-impl'" + +extra_option_pattern_unsafe_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + ubproject: | + [[needs.extra_options]] + name = "efforts" + schema.type = "string" + schema.pattern = "^IMPL_(?!SAFE)" + rst: | + .. impl:: title + :id: IMPL_1 + exception: + - "Unsafe pattern '^IMPL_(?!SAFE)' at 'extra_options.efforts.schema'" + - "contains lookahead/lookbehind assertions" + +extra_link_pattern_unsafe_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.items = { pattern = "^IMPL_(?!SAFE)" } + rst: | + .. impl:: title + :id: IMPL_1 + exception: + - "Unsafe pattern '^IMPL_(?!SAFE)' at 'extra_links.links.schema.items'" + - "contains lookahead/lookbehind assertions" + +schemas_select_pattern_unsafe_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - select: + properties: + id: {pattern: "^IMPL_(?!SAFE)"} + validate: + local: + required: [id] + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^IMPL_(?!SAFE)' at 'schemas.[0].select.properties.id'" + - "contains lookahead/lookbehind assertions" + +schemas_validate_pattern_unsafe_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^IMPL_(?!SAFE)" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^IMPL_(?!SAFE)' at 'schemas.[0].validate.local.properties.id'" + - "contains lookahead/lookbehind assertions" + +schemas_validate_pattern_backref_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^IMPL_(SAFE)_\\1" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^IMPL_(SAFE)_\\1' at 'schemas.[0].validate.local.properties.id'" + - " contains backreferences" + +schemas_validate_pattern_nested_quantifiers_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^(a+)+$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^(a+)+$' at 'schemas.[0].validate.local.properties.id'" + - "contains nested quantifiers that may cause backtracking" + +schemas_validate_pattern_recursive_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^test(?R)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^test(?R)$' at 'schemas.[0].validate.local.properties.id'" + - "invalid regex syntax: unknown extension ?R at position 6" + +schemas_validate_pattern_possessive_quantifiers_error: + mark: + min_python: [3, 11] + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^test*+$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^test*+$' at 'schemas.[0].validate.local.properties.id'" + - "contains possessive quantifiers" + +schemas_validate_pattern_atomic_groups_error: + mark: + min_python: [3, 11] + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^(?>test)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^(?>test)$' at 'schemas.[0].validate.local.properties.id'" + - "contains special groups (other than non-capturing)" + +schemas_validate_pattern_unicode_property_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^\\p{L}+$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^\\p{L}+$' at 'schemas.[0].validate.local.properties.id'" + - "invalid regex syntax: bad escape \\p at position 1" + +schemas_validate_pattern_named_groups_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^(?Ptest)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^(?Ptest)$' at 'schemas.[0].validate.local.properties.id'" + - "contains special groups (other than non-capturing)" + +schemas_validate_pattern_unnamed_groups_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^(test)?(?(1)yes|no)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^(test)?(?(1)yes|no)$' at 'schemas.[0].validate.local.properties.id'" + - "contains special groups (other than non-capturing)" + +schemas_validate_pattern_comment_groups_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^test(?#comment)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^test(?#comment)$' at 'schemas.[0].validate.local.properties.id'" + - "contains special groups (other than non-capturing)" + +schemas_validate_pattern_conditional_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^(?(1)yes|no)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^(?(1)yes|no)$' at 'schemas.[0].validate.local.properties.id'" + - "invalid regex syntax: invalid group reference 1 at position 4" + +schemas_validate_pattern_unicode_escapes_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^\\u0041$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^\\u0041$' at 'schemas.[0].validate.local.properties.id'" + - "contains Unicode and control character escapes" + +schemas_validate_pattern_subroutine_calls_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^test(?&name)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^test(?&name)$' at 'schemas.[0].validate.local.properties.id'" + - "invalid regex syntax: unknown extension ?& at position 6" + +schemas_validate_pattern_relative_subroutine_error: + conf: | + extensions = ["sphinx_needs"] + needs_schema_definitions_from_json = "schemas.json" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + schemas: + - validate: + local: + properties: + id: + pattern: "^test(?+1)$" + exception: + - "Schemas entry '[0]' is not valid" + - "Unsafe pattern '^test(?+1)$' at 'schemas.[0].validate.local.properties.id'" + - "invalid regex syntax: unknown extension ?+ at position 6" diff --git a/tests/schema/fixtures/extra_links.yml b/tests/schema/fixtures/extra_links.yml new file mode 100644 index 000000000..a4e92293f --- /dev/null +++ b/tests/schema/fixtures/extra_links.yml @@ -0,0 +1,341 @@ +beta_warn_for_extra_link: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.minItems = 1 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: + - - "Schema interface and validation are still in beta" + - "Interface and validation logic may change when moving to a typed core implementation" + - sphinx8: ["needs.beta"] + +inject_array: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.minItems = 2 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: + - - "Severity # violation" + - "Field # links" + - "Need path # IMPL_1" + - "Schema path # extra_links > schema > properties > links > minItems" + - "Schema message # ['SPEC_1'] is too short" + - sphinx8: ["sn_schema.extra_link_fail"] + +items_pattern: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.items = { type = "string", pattern = "^[A-Z0-9_]{3,}" } + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: [] + +items_pattern_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.items = { type = "string", pattern = "^[A-Z0-9_]{10,}" } + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: + - - "Severity # violation" + - "Field # links.0" + - "Need path # IMPL_1" + - "Schema path # extra_links > schema > properties > links > items > pattern" + - "Schema message # 'SPEC_1' does not match '^[A-Z0-9_]{10,}'" + - sphinx8: ["sn_schema.extra_link_fail"] + +min_items: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.type = "array" + schema.minItems = 1 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: [] + +min_items_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.type = "array" + schema.minItems = 2 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: + - - "Severity # violation" + - "Field # links" + - "Need path # IMPL_1" + - "Schema path # extra_links > schema > properties > links > minItems" + - "Schema message # ['SPEC_1'] is too short" + - sphinx8: ["sn_schema.extra_link_fail"] + +max_items: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.type = "array" + schema.maxItems = 1 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: [] + +max_items_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.maxItems = 1 + rst: | + .. spec:: title + :id: SPEC_1 + + .. spec:: title + :id: SPEC_2 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1, SPEC_2 + warnings: + - - "Severity # violation" + - "Field # links" + - "Need path # IMPL_1" + - "Schema path # extra_links > schema > properties > links > maxItems" + - "Schema message # ['SPEC_1', 'SPEC_2'] is too long" + - sphinx8: ["sn_schema.extra_link_fail"] + +contains: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.contains = { pattern = "^SPEC_" } + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: [] + +contains_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.contains = { pattern = "^REQ_" } + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: + - - "Severity # violation" + - "Field # links" + - "Need path # IMPL_1" + - "Schema path # extra_links > schema > properties > links > contains" + - "Schema message # ['SPEC_1'] does not contain items matching the given schema" + - sphinx8: ["sn_schema.extra_link_fail"] + +min_contains: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.contains = { pattern = "^SPEC_" } + schema.minContains = 1 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: [] + +min_contains_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.type = "array" + schema.contains = { pattern = "^SPEC_" } + schema.minContains = 2 + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: + - - "Severity # violation" + - "Field # links" + - "Need path # IMPL_1" + - "Schema path # extra_links > schema > properties > links > contains" + - "Schema message # Too few items match the given schema (expected at least 2 but only 1 matched)" + - sphinx8: ["sn_schema.extra_link_fail"] + +max_contains: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.contains = { pattern = "^SPEC_" } + schema.maxContains = 2 + rst: | + .. spec:: title + :id: SPEC_1 + + .. spec:: title + :id: SPEC_2 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1, SPEC_2 + warnings: [] + +max_contains_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_links]] + option = "links" + outgoing = "links" + incoming = "linked by" + schema.type = "array" + schema.contains = { pattern = "^SPEC_" } + schema.maxContains = 1 + rst: | + .. spec:: title + :id: SPEC_1 + + .. spec:: title + :id: SPEC_2 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1, SPEC_2 + warnings: + - - "Severity # violation" + - "Field # links" + - "Need path # IMPL_1" + - "Schema path # extra_links > schema > properties > links > contains" + - "Schema message # Schema message: Too many items match the given schema (expected at most 1)" + - sphinx8: ["sn_schema.extra_link_fail"] diff --git a/tests/schema/fixtures/extra_options.yml b/tests/schema/fixtures/extra_options.yml new file mode 100644 index 000000000..0d7b03857 --- /dev/null +++ b/tests/schema/fixtures/extra_options.yml @@ -0,0 +1,982 @@ +no_schema: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + warnings: [] + +beta_warn_for_extra_option: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.type = "string" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + warnings: + - - "Schema interface and validation are still in beta" + - "Interface and validation logic may change when moving to a typed core implementation" + - sphinx8: ["needs.beta"] + +set_type_string: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.type = "string" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + warnings: [] + +auto_inject_string: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.enum = ["QM"] + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + warnings: [] + +wrong_type: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.type = "integer" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + warnings: + - - "Severity # violation" + - "Field # asil" + - "Need path # IMPL_1" + - "Schema message # Field 'asil': cannot coerce 'QM' to integer" + - sphinx8: ["sn_schema.extra_option_type_error"] + +const: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + schemas: + $defs: [] + schemas: + - validate: + local: + properties: + asil: + type: string + const: QM + warnings: [] + +const_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: A + schemas: + $defs: [] + schemas: + - validate: + local: + properties: + asil: + type: string + const: QM + warnings: + - - "Severity # violation" + - "Field # asil" + - "Need path # IMPL_1" + - "Schema path # [0] > local > properties > asil > const" + - "Schema message # 'QM' was expected" + - sphinx8: ["sn_schema.local_fail"] + +enum: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + schemas: + $defs: [] + schemas: + - validate: + local: + properties: + asil: + type: string + enum: ["QM", "A", "B", "C", "D"] + warnings: [] + +enum_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: E + schemas: + $defs: [] + schemas: + - validate: + local: + properties: + asil: + type: string + enum: ["QM", "A", "B", "C", "D"] + warnings: + - - "Severity # violation" + - "Field # asil" + - "Need path # IMPL_1" + - "Schema path # [0] > local > properties > asil > enum" + - "Schema message # 'E' is not one of ['QM', 'A', 'B', 'C', 'D']" + - sphinx8: ["sn_schema.local_fail"] + +auto_inject_type: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + schemas: + $defs: [] + schemas: + - validate: + local: + properties: + asil: + const: QM + warnings: [] + +required: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + schemas: + $defs: [] + schemas: + - validate: + local: + required: ["asil"] + warnings: [] + +required_missing: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + $defs: [] + schemas: + - validate: + local: + required: ["asil"] + warnings: + - - "Severity # violation" + - "Field # asil" + - "Need path # IMPL_1" + - "Schema path # [0] > local > required" + - "Schema message # 'asil' is a required property" + - sphinx8: ["sn_schema.local_fail"] + +auto_inject_type_wrong_const: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: A + schemas: + $defs: [] + schemas: + - validate: + local: + properties: + asil: + const: QM + warnings: + - - "Severity # violation" + - "Field # asil" + - "Need path # IMPL_1" + - "Schema path # [0] > local > properties > asil > const" + - "Schema message # 'QM' was expected" + - sphinx8: ["sn_schema.local_fail"] + +required_based_on_select_field_missing: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + [[needs.extra_options]] + name = "efforts" + schema.type = "integer" + rst: | + .. impl:: title + :id: IMPL_1 + schemas: + $defs: [] + schemas: + - select: + properties: + efforts: + minimum: 15 + required: ["efforts"] + validate: + local: + required: ["asil"] + warnings: [] + +required_based_on_select_field_below_threshold: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + [[needs.extra_options]] + name = "efforts" + schema.type = "integer" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 14 + schemas: + $defs: [] + schemas: + - select: + properties: + efforts: + minimum: 15 + required: ["efforts"] + validate: + local: + required: ["asil"] + warnings: [] + +required_based_on_select_field_over_threshold: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + [[needs.extra_options]] + name = "efforts" + schema.type = "integer" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 15 + schemas: + $defs: [] + schemas: + - select: + properties: + efforts: + minimum: 15 + required: ["efforts"] + validate: + local: + required: ["asil"] + warnings: + - - "Severity # violation" + - "Need path # IMPL_1" + - "Schema path # [0] > local > required" + - "Schema message # 'asil' is a required property" + - sphinx8: ["sn_schema.local_fail"] + +string_type: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.type = "string" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + warnings: [] + +string_format_date: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "start_date" + schema.type = "string" + schema.format = "date" + rst: | + .. impl:: title + :id: IMPL_1 + :start_date: 2023-01-01 + warnings: [] + +string_format_date_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "start_date" + schema.type = "string" + schema.format = "date" + rst: | + .. impl:: title + :id: IMPL_1 + :start_date: not-a-date + warnings: + - - "Severity # violation" + - "Field # start_date" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > start_date > format" + - "Schema message # 'not-a-date' is not a 'date'" + - sphinx8: ["sn_schema.extra_option_fail"] + +string_format_date_time: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "start_date" + schema.type = "string" + schema.format = "date-time" + rst: | + .. impl:: title + :id: IMPL_1 + :start_date: 2023-01-01T00:00:00Z + warnings: [] + +string_format_date_time_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "start_date" + schema.type = "string" + schema.format = "date-time" + rst: | + .. impl:: title + :id: IMPL_1 + :start_date: 2025-07-1099:99:99Z + warnings: + - - "Severity # violation" + - "Field # start_date" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > start_date > format" + - "Schema message # '2025-07-1099:99:99Z' is not a 'date-time'" + - sphinx8: ["sn_schema.extra_option_fail"] + +string_format_time: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "time" + schema.type = "string" + schema.format = "time" + rst: | + .. impl:: title + :id: IMPL_1 + :time: 23:12:13 + warnings: [] + +string_format_time_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "time" + schema.type = "string" + schema.format = "time" + rst: | + .. impl:: title + :id: IMPL_1 + :time: 26:12:13 + warnings: + - - "Severity # violation" + - "Field # time" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > time > format" + - "Schema message # '26:12:13' is not a 'time'" + - sphinx8: ["sn_schema.extra_option_fail"] + +string_format_duration: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "start_date" + schema.type = "string" + schema.format = "duration" + rst: | + .. impl:: title + :id: IMPL_1 + :start_date: P1Y2M10DT2H30M + warnings: [] + +string_format_duration_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "duration" + schema.type = "string" + schema.format = "duration" + rst: | + .. impl:: title + :id: IMPL_1 + :duration: P1Q2Q10DT2H30M + warnings: + - - "Severity # violation" + - "Field # duration" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > duration > format" + - "Schema message # 'P1Q2Q10DT2H30M' is not a 'duration'" + - sphinx8: ["sn_schema.extra_option_fail"] + +string_format_email: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "email" + schema.type = "string" + schema.format = "email" + rst: | + .. impl:: title + :id: IMPL_1 + :email: test@example.com + warnings: [] + +string_format_email_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "email" + schema.type = "string" + schema.format = "email" + rst: | + .. impl:: title + :id: IMPL_1 + :email: not-a-mail + warnings: + - - "Severity # violation" + - "Field # email" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > email > format" + - "Schema message # 'not-a-mail' is not a 'email'" + - sphinx8: ["sn_schema.extra_option_fail"] + +string_format_uri: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "uri" + schema.type = "string" + schema.format = "uri" + rst: | + .. impl:: title + :id: IMPL_1 + :uri: https:://example.com + warnings: [] + +string_format_uri_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "uri" + schema.type = "string" + schema.format = "uri" + rst: | + .. impl:: title + :id: IMPL_1 + :uri: examplecom + warnings: + - - "Severity # violation" + - "Field # uri" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > uri > format" + - "Schema message # 'examplecom' is not a 'uri'" + - sphinx8: ["sn_schema.extra_option_fail"] + +string_format_uuid: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "uuid" + schema.type = "string" + schema.format = "uuid" + rst: | + .. impl:: title + :id: IMPL_1 + :uuid: 123e4567-e89b-12d3-a456-426614174000 + warnings: [] + +string_format_uuid_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "uuid" + schema.type = "string" + schema.format = "uuid" + rst: | + .. impl:: title + :id: IMPL_1 + :uuid: deadbeef-deadbeef-deadbeef-deadbeef + warnings: + - - "Severity # violation" + - "Field # uuid" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > uuid > format" + - "Schema message # 'deadbeef-deadbeef-deadbeef-deadbeef' is not a 'uuid'" + - sphinx8: ["sn_schema.extra_option_fail"] + +coerce_to_integer: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts1" + schema.type = "integer" + [[needs.extra_options]] + name = "efforts2" + schema.type = "integer" + [[needs.extra_options]] + name = "efforts3" + schema.type = "integer" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts1: 1 + :efforts2: 0 + :efforts3: -1 + warnings: [] + +integer_multiple_of: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts1" + schema.type = "integer" + schema.multipleOf = 3 + [[needs.extra_options]] + name = "efforts2" + schema.type = "integer" + schema.multipleOf = -1 + [[needs.extra_options]] + name = "efforts3" + schema.type = "integer" + schema.multipleOf = -1 + rst: | + .. impl:: title + :id: IMPL_1 + :efforts1: 9 + :efforts2: -4 + :efforts3: 4 + warnings: [] + +integer_multiple_of_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts" + schema.type = "integer" + schema.multipleOf = 3 + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 8 + warnings: + - - "Severity # violation" + - "Field # efforts" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > efforts > multipleOf" + - "Schema message # 8 is not a multiple of 3" + - sphinx8: ["sn_schema.extra_option_fail"] + +number_multiple_of: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts1" + schema.type = "number" + schema.multipleOf = 3.3 + [[needs.extra_options]] + name = "efforts2" + schema.type = "number" + schema.multipleOf = -1.1 + [[needs.extra_options]] + name = "efforts3" + schema.type = "number" + schema.multipleOf = 1.2 + rst: | + .. impl:: title + :id: IMPL_1 + :efforts1: 6.6 + :efforts2: 4.4 + :efforts3: -2.4 + warnings: [] + +number_multiple_of_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts" + schema.type = "number" + schema.multipleOf = 3.3 + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 5.0 + warnings: + - - "Severity # violation" + - "Field # efforts" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema > properties > efforts > multipleOf" + - "Schema message # 5.0 is not a multiple of 3.3" + - sphinx8: ["sn_schema.extra_option_fail"] + +coerce_to_integer_from_string_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts" + schema.type = "integer" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: QM + warnings: + - - "Severity # violation" + - "Field # efforts" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema" + - "Schema message # Field 'efforts': cannot coerce 'QM' to integer" + - sphinx8: ["sn_schema.extra_option_type_error"] + +coerce_to_number: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts1" + schema.type = "number" + [[needs.extra_options]] + name = "efforts2" + schema.type = "number" + [[needs.extra_options]] + name = "efforts3" + schema.type = "number" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts1: 1.0 + :efforts2: 0 + :efforts3: -1.2 + warnings: [] + +coerce_to_number_from_string_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts" + schema.type = "number" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: QM + warnings: + - - "Severity # violation" + - "Field # efforts" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema" + - "Schema message # Field 'efforts': cannot coerce 'QM' to number" + - sphinx8: ["sn_schema.extra_option_type_error"] + +coerce_to_integer_from_float_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts" + schema.type = "integer" + rst: | + .. impl:: title + :id: IMPL_1 + :efforts: 1.2 + warnings: + - - "Severity # violation" + - "Field # efforts" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema" + - "Schema message # Field 'efforts': cannot coerce '1.2' to integer" + - sphinx8: ["sn_schema.extra_option_type_error"] + +coerce_to_boolean: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "accepted" + schema.type = "boolean" + rst: | + .. impl:: title + :id: IMPL_1 + :accepted: True + + .. impl:: title + :id: IMPL_2 + :accepted: true + + .. impl:: title + :id: IMPL_3 + :accepted: false + + .. impl:: title + :id: IMPL_4 + :accepted: False + + .. impl:: title + :id: IMPL_5 + :accepted: yes + + .. impl:: title + :id: IMPL_6 + :accepted: Yes + + .. impl:: title + :id: IMPL_7 + :accepted: no + + .. impl:: title + :id: IMPL_8 + :accepted: No + + .. impl:: title + :id: IMPL_9 + :accepted: 1 + + .. impl:: title + :id: IMPL_10 + :accepted: 0 + + .. impl:: title + :id: IMPL_11 + :accepted: on + + .. impl:: title + :id: IMPL_12 + :accepted: On + + .. impl:: title + :id: IMPL_13 + :accepted: off + + .. impl:: title + :id: IMPL_14 + :accepted: Off + warnings: [] + +coerce_to_boolean_from_string_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "approved" + schema.type = "boolean" + rst: | + .. impl:: title + :id: IMPL_1 + :approved: not-a-boolean + warnings: + - - "Severity # violation" + - "Field # approved" + - "Need path # IMPL_1" + - "Schema path # extra_options > schema" + - "Schema message # Field 'approved': cannot coerce 'not-a-boolean' to boolean" + - sphinx8: ["sn_schema.extra_option_type_error"] + +default_accepted: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "efforts1" + schema.type = "number" + schema.default = 0 + [[needs.extra_options]] + name = "efforts2" + schema.type = "string" + schema.default = "high" + [[needs.extra_options]] + name = "approved" + schema.type = "boolean" + schema.default = false + rst: | + .. impl:: title + :id: IMPL_1 + :efforts1: 6.6 + :efforts2: 4.4 + :approved: true + + .. impl:: title + :id: IMPL_2 + :efforts1: 6.6 + :efforts2: 4.4 + warnings: [] diff --git a/tests/schema/fixtures/network.yml b/tests/schema/fixtures/network.yml new file mode 100644 index 000000000..e926490d5 --- /dev/null +++ b/tests/schema/fixtures/network.yml @@ -0,0 +1,669 @@ +schemas_in_conf: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions = { + "schemas": [ + { + "select": { + "properties": { + "type": {"const": "impl"} + } + }, + "validate": { + "network": { + "links": { + "contains": { + "local": { + "properties": {"type": {"const": "spec"}} + } + }, + "minContains": 1 + } + } + } + } + ] + } + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: [] + +schemas_in_conf_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions = { + "schemas": [ + { + "select": { + "properties": { + "type": {"const": "impl"} + } + }, + "validate": { + "network": { + "links": { + "contains": { + "local": { + "properties": {"type": {"const": "req"}} + } + }, + "minContains": 1 + } + } + } + } + ] + } + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + warnings: + - - "Severity # violation" + - "Field # links" + - "Need path # IMPL_1 > links" + - "Schema path # [0] > validate > network > links" + - "Schema message # Too few valid links of type 'links' (0 < 1) / nok: SPEC_1" + - sphinx8: ["sn_schema.network_contains_too_few"] + +min_contains: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + schemas: + $defs: [] + schemas: + - select: + properties: + type: + const: "impl" + validate: + network: + links: + "contains": + "local": + "properties": + "type": + "const": "spec" + minContains: 1 + warnings: [] + +min_contains_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + schemas: + $defs: [] + schemas: + - select: + properties: + type: + const: "impl" + validate: + network: + links: + "contains": + "local": + "properties": + "type": + "const": "spec" + minContains: 2 + warnings: + - - "Severity # violation" + - "Need path # IMPL_1 > links" + - "Schema path # [0] > validate > network > links" + - "Schema message # Too few valid links of type 'links' (1 < 2) / ok: SPEC_1" + - sphinx8: ["sn_schema.network_contains_too_few"] + +max_contains: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + schemas: + $defs: [] + schemas: + - select: + properties: + type: + const: "impl" + validate: + network: + links: + "contains": + "local": + "properties": + "type": + "const": "spec" + maxContains: 1 + warnings: [] + +max_contains_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. spec:: title + :id: SPEC_2 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1, SPEC_2 + schemas: + $defs: [] + schemas: + - select: + properties: + type: {const: "impl"} + validate: + network: + links: + "contains": + "local": + "properties": + "type": + "const": "spec" + maxContains: 1 + warnings: + - - "Severity # violation" + - "Need path # IMPL_1 > links" + - "Schema path # [0] > validate > network > links" + - "Schema message # Too many valid links of type 'links' (2 > 1) / ok: SPEC_1, SPEC_2" + - sphinx8: ["sn_schema.network_contains_too_many"] + +link_chain_w_refs: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.enum = ["QM", "A", "B", "C", "D"] + rst: | + .. req:: safe req + :id: REQ_SAFE + :asil: C + + .. spec:: safe spec + :id: SPEC_SAFE + :asil: B + :links: REQ_SAFE + + .. impl:: safe impl + :id: IMPL_SAFE + :asil: A + :links: SPEC_SAFE + schemas: + $defs: + type-req: + properties: + type: + const: req + type-spec: + properties: + type: + const: spec + type-impl: + properties: + type: + const: impl + safe-req: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-req" + safe-spec: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-spec" + safe-impl: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-impl" + safe-need: + properties: + asil: + enum: [A, B, C, D] + required: + - asil + schemas: + - id: "safe-impl-[links]->safe-spec" + message: Safe impl links to safe spec + select: + $ref: "#/$defs/safe-impl" + validate: + network: + links: + contains: + local: + $ref: "#/$defs/safe-spec" + minContains: 1 + maxContains: 4 + - id: "safe-impl-[links]->safe-spec-[links]->safe-req" + message: Safe impl links to safe spec links to safe req + select: + $ref: "#/$defs/safe-impl" + validate: + network: + links: + contains: + local: + $ref: "#/$defs/safe-spec" + network: + links: + contains: + local: + $ref: "#/$defs/safe-req" + minContains: 1 + minContains: 1 + warnings: [] + +link_chain_hop_1_min_contains_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.enum = ["QM", "A", "B", "C", "D"] + rst: | + .. spec:: unsafe spec + :id: SPEC_UNSAFE + :asil: QM + + .. impl:: safe impl + :id: IMPL_SAFE + :asil: A + :links: SPEC_UNSAFE + schemas: + $defs: + type-spec: + properties: + type: + const: spec + type-impl: + properties: + type: + const: impl + safe-spec: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-spec" + safe-impl: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-impl" + safe-need: + properties: + asil: + enum: [A, B, C, D] + required: + - asil + schemas: + - id: "safe-impl-[links]->safe-spec" + message: Safe impl links to safe spec + select: + $ref: "#/$defs/safe-impl" + validate: + network: + links: + contains: + local: + $ref: "#/$defs/safe-spec" + minContains: 1 + warnings: + - - "Severity # violation" + - "Need path # IMPL_SAFE > links" + - "Schema path # safe-impl-[links]->safe-spec[0] > validate > network > links" + - "Schema message # Too few valid links of type 'links' (0 < 1) / nok: SPEC_UNSAFE" + - sphinx8: ["sn_schema.network_contains_too_few"] + +link_chain_hop_2_min_contains_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + schema.enum = ["QM", "A", "B", "C", "D"] + rst: | + .. req:: unsafe req + :id: REQ_UNSAFE + :asil: QM + + .. spec:: safe spec + :id: SPEC_SAFE + :asil: B + :links: REQ_UNSAFE + + .. impl:: safe impl + :id: IMPL_SAFE + :asil: A + :links: SPEC_SAFE + schemas: + $defs: + type-req: + properties: + type: + const: req + type-spec: + properties: + type: + const: spec + type-impl: + properties: + type: + const: impl + safe-req: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-req" + safe-spec: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-spec" + safe-impl: + allOf: + - $ref: "#/$defs/safe-need" + - $ref: "#/$defs/type-impl" + safe-need: + properties: + asil: + enum: [A, B, C, D] + required: + - asil + schemas: + - id: "safe-impl-[links]->safe-spec-[links]->safe-req" + message: Safe impl links to safe spec links to safe req + select: + $ref: "#/$defs/safe-impl" + validate: + network: + links: + contains: + local: + $ref: "#/$defs/safe-spec" + network: + links: + contains: + local: + $ref: "#/$defs/safe-req" + minContains: 1 + minContains: 1 + warnings: + - - "Severity # violation" + - "Need path # IMPL_SAFE > links" + - "Schema path # safe-impl-[links]->safe-spec-[links]->safe-req[0] > validate > network > links" + - "Schema message # Too few valid links of type 'links' (0 < 1) / nok: SPEC_SAFE" + - "Details for SPEC_SAFE" + - "Schema message # Too few valid links of type 'links' (0 < 1) / nok: REQ_UNSAFE" + - "Details for REQ_UNSAFE" + - "Field # asil" + - "Need path # IMPL_SAFE > links > SPEC_SAFE > links > REQ_UNSAFE" + - "Schema path # safe-impl-[links]->safe-spec-[links]->safe-req[0] > links > links > local > allOf > 0 > properties > asil > enum" + - "Schema message # 'QM' is not one of ['A', 'B', 'C', 'D']" + - sphinx8: ["sn_schema.network_contains_too_few"] + +local_min_items: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + schemas: + $defs: [] + schemas: + - select: + properties: + type: {const: "impl"} + validate: + local: + properties: + links: {minItems: 1} + warnings: [] + +local_min_items_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1 + schemas: + $defs: [] + schemas: + - select: + properties: + type: {const: "impl"} + validate: + local: + properties: + links: {minItems: 2} + warnings: + - - "Severity # violation" + - "Need path # IMPL_1" + - "Schema path # [0] > local > properties > links > minItems" + - "Schema message # ['SPEC_1'] is too short" + - sphinx8: ["sn_schema.local_fail"] + +local_max_items: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. spec:: title + :id: SPEC_2 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1, SPEC_2 + schemas: + $defs: [] + schemas: + - select: + properties: + type: {const: "impl"} + validate: + local: + properties: + links: {maxItems: 2} + warnings: [] + +local_max_items_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. spec:: title + :id: SPEC_2 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_1, SPEC_2 + schemas: + $defs: [] + schemas: + - select: + properties: + type: {const: "impl"} + validate: + local: + properties: + links: {maxItems: 1} + warnings: + - - "Severity # violation" + - "Need path # IMPL_1" + - "Schema path # [0] > local > properties > links > maxItems" + - "Schema message # ['SPEC_1', 'SPEC_2'] is too long" + - sphinx8: ["sn_schema.local_fail"] + +max_network_levels: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + rst: | + .. spec:: title + :id: SPEC_1 + + .. spec:: title + :id: SPEC_2 + :links: SPEC_1 + + .. spec:: title + :id: SPEC_3 + :links: SPEC_2 + + .. spec:: title + :id: SPEC_4 + :links: SPEC_3 + + .. spec:: title + :id: SPEC_5 + :links: SPEC_4 + + .. impl:: title + :id: IMPL_1 + :links: SPEC_5 + schemas: + $defs: [] + schemas: + - select: + properties: + type: + const: impl + validate: + network: + links: + items: + network: + links: + items: + network: + links: + items: + network: + links: + items: + network: + links: + items: + network: + links: + items: + local: + properties: + type: + const: spec + warnings: + - - "Need 'SPEC_1' has validation errors" + - "Severity # violation" + - "Need path # IMPL_1 > links > SPEC_5 > links > SPEC_4 > links > SPEC_3 > links > SPEC_2 > links > SPEC_1" + - "Schema path # [0] > links > links > links > links > links" + - "Schema message # Maximum network validation recursion level 4 reached" + - sphinx8: ["sn_schema.network_max_nest_level"] diff --git a/tests/schema/fixtures/unevaluated.yml b/tests/schema/fixtures/unevaluated.yml new file mode 100644 index 000000000..60159ba03 --- /dev/null +++ b/tests/schema/fixtures/unevaluated.yml @@ -0,0 +1,63 @@ +unevaluated_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + [[needs.extra_options]] + name = "comment" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + :comment: This is a comment + schemas: + schemas: + - validate: + local: + properties: {"asil": {}} + unevaluatedProperties: false + warnings: + - - "Severity # violation" + - "Need path # IMPL_1" + - "Schema path # [0] > local > unevaluatedProperties" + - "Schema message # Unevaluated properties are not allowed ('comment' was unexpected)" + - sphinx8: ["sn_schema.local_fail"] + +unevaluated_allof_error: + conf: | + extensions = ["sphinx_needs"] + needs_from_toml = "ubproject.toml" + needs_schema_definitions_from_json = "schemas.json" + suppress_warnings = ["needs.beta"] + ubproject: | + [[needs.extra_options]] + name = "asil" + [[needs.extra_options]] + name = "comment" + [[needs.extra_options]] + name = "approved" + schema.type = "boolean" + rst: | + .. impl:: title + :id: IMPL_1 + :asil: QM + :comment: This is a comment + :approved: true + schemas: + schemas: + - validate: + local: + properties: {"asil": {}} + unevaluatedProperties: false + allOf: + - properties: {"comment": {}} + warnings: + - - "Severity # violation" + - "Need path # IMPL_1" + - "Schema path # [0] > local > unevaluatedProperties" + - "Schema message # Unevaluated properties are not allowed ('approved' was unexpected)" + - sphinx8: ["sn_schema.local_fail"] diff --git a/tests/schema/test_schema.py b/tests/schema/test_schema.py new file mode 100644 index 000000000..c2a1d7f6a --- /dev/null +++ b/tests/schema/test_schema.py @@ -0,0 +1,130 @@ +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any, Literal + +import pytest +import sphinx +from sphinx.testing.util import SphinxTestApp + +from sphinx_needs.exceptions import NeedsConfigException + +CURR_DIR = Path(__file__).parent + + +@pytest.mark.fixture_file( + "schema/fixtures/config.yml", +) +def test_schema_config( + tmpdir: Path, + content: dict[str, Any], + make_app: Callable[[], SphinxTestApp], + write_fixture_files: Callable[[Path, dict[str, Any]], None], +): + # Check if test should be skipped based on min_python version + if "mark" in content and "min_python" in content["mark"]: + min_version = tuple(content["mark"]["min_python"]) + if sys.version_info < min_version: + pytest.skip( + f"Test requires Python {'.'.join(map(str, min_version))} or higher" + ) + write_fixture_files(tmpdir, content) + assert "exception" in content + with pytest.raises(NeedsConfigException) as excinfo: + make_app(srcdir=Path(tmpdir), freshenv=True) + for snippet in content["exception"]: + assert snippet in str(excinfo.value), ( + f"Expected exception message '{content['exception']}' not found in: {excinfo.value}" + ) + + +@pytest.mark.fixture_file( + "schema/fixtures/extra_links.yml", + "schema/fixtures/extra_options.yml", + "schema/fixtures/network.yml", + "schema/fixtures/unevaluated.yml", +) +def test_schemas( + tmpdir: Path, + content: dict[str, Any], + make_app: Callable[[], SphinxTestApp], + write_fixture_files: Callable[[Path, dict[str, Any]], None], + check_ontology_warnings: Callable[ + [SphinxTestApp, list[list[str | dict[Literal["sphinx8"], list[str]]]]], None + ], +): + write_fixture_files(tmpdir, content) + + app: SphinxTestApp = make_app(srcdir=Path(tmpdir), freshenv=True) + app.build() + + assert app.statuscode == 0 + check_ontology_warnings(app, content["warnings"]) + app.cleanup() + + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/doc_schema_e2e", "no_plantuml": True}], + indirect=True, +) +def test_schema_e2e( + test_app: SphinxTestApp, get_warnings_list: Callable[[SphinxTestApp], list[str]] +) -> None: + test_app.builder.build_all() + warnings = get_warnings_list(test_app) + + expected_warnings = [ + ( + "'FEAt' does not match '^[A-Z0-9_]+$'", + "[sn_schema.local_fail]", + ), + ( + "Unevaluated properties are not allowed ('asil', 'priority' were unexpected)", + "[sn_schema.local_fail]", + ), + ( + "'approved' is a required property", + "[sn_schema.local_fail]", + ), + ( + "'SPEC' does not match '^SPEC_[a-zA-Z0-9_-]*$'", + "[sn_schema.local_fail]", + ), + ( + "Unevaluated properties are not allowed ('approved', 'asil', 'links', 'priority' were unexpected)", + "[sn_schema.local_fail]", + ), + ( + "Too few valid links of type 'links' (0 < 1) / nok: FEAT", + "[sn_schema.network_contains_too_few]", + ), + ( + "Unevaluated properties are not allowed ('approved', 'asil', 'links', 'priority' were unexpected)", + "[sn_schema.local_fail]", + ), + ( + "Unevaluated properties are not allowed ('approved', 'asil', 'links', 'priority' were unexpected)", + "[sn_schema.local_fail]", + ), + ] + for expected in expected_warnings: + assert any(expected[0] in warning for warning in warnings), ( + f"Expected warning not found: {expected[0]}" + ) + if sphinx.version_info[0] >= 8: + assert any(expected[1] in warning for warning in warnings), ( + f"Expected subtype not found: {expected[1]}" + ) + + assert len(warnings) == len(expected_warnings) + unexpected_warnings = [ + '"approved" is a required property [sn_schema.validation_fail]', # severity info too low + ] + for unexpected in unexpected_warnings: + assert all(unexpected not in warning for warning in warnings), ( + f"Unexpected warning found: {unexpected}" + ) + + html = Path(test_app.outdir, "index.html").read_text() + assert html