diff --git a/docs/configuration.rst b/docs/configuration.rst index 0ed810299..ad230cae1 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1939,6 +1939,22 @@ constraints_passed is a bool showing if ALL constraints of a corresponding need constraints_results is a dictionary similar in structure to needs_constraints above. Instead of executable python statements, inner values contain a bool describing if check_0, check_1 ... passed successfully. +.. versionadded:: 1.4.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``: + + .. code-block:: python + + needs_constraints = { + + "critical": { + "check_0": "'critical' in tags", + "severity": "CRITICAL", + "error_message": "need {% raw %}{{id}}{% endraw %} does not fulfill CRITICAL constraint, because tags are {% raw %}{{tags}}{% endraw %}" + } + + } + .. code-block:: rst diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index 1e29ec713..da452c424 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -232,7 +232,12 @@ def __setattr__(self, name: str, value: Any) -> None: """path to needs_report_template file which is based on the conf.py directory.""" constraints: dict[str, dict[str, str]] = field(default_factory=dict, metadata={"rebuild": "html", "types": (dict,)}) - """Mapping of constraint name, to check name, to filter string.""" + """Mapping of constraint name, to check name, to filter string. + There are also some special keys for a constraint: + + - severity: The severity of the constraint. This is used to determine what to do if the constraint is not fulfilled. + - error_message: A help text for the constraint, can include Jinja2 variables. + """ constraint_failed_options: dict[str, ConstraintFailedType] = field( default_factory=dict, metadata={"rebuild": "html", "types": (dict,)} ) diff --git a/sphinx_needs/data.py b/sphinx_needs/data.py index 521c169cb..3c8ddaa7d 100644 --- a/sphinx_needs/data.py +++ b/sphinx_needs/data.py @@ -44,6 +44,7 @@ class NeedsWorkflowType(TypedDict): add_sections: bool variant_option_resolved: bool needs_extended: bool + needs_constraints: bool class NeedsBaseDataType(TypedDict): @@ -166,6 +167,8 @@ class NeedsInfoType(NeedsBaseDataType): """Mapping of constraint name, to check name, to result.""" constraints_passed: None | bool """True if all constraints passed, False if any failed, None if not yet checked.""" + constraints_error: str + """An error message set if any constraint failed, and `error_message` field is set in config.""" # additional source information doctype: str @@ -460,8 +463,8 @@ def get_or_create_workflow(self) -> NeedsWorkflowType: "add_sections": False, "variant_option_resolved": False, "needs_extended": False, + "needs_constraints": False, } - # TODO use needs_config here for link_type in self.env.app.config.needs_extra_links: self.env.needs_workflow["backlink_creation_{}".format(link_type["option"])] = False diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index 3f4385a95..7a0987fb6 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -394,28 +394,13 @@ def process_need_nodes(app: Sphinx, doctree: nodes.document, fromdocname: str) - for links in needs_config.extra_links: create_back_links(env, links["option"]) - """ - The output of this phase is a doctree for each source file; that is a tree of docutils nodes. - - https://www.sphinx-doc.org/en/master/extdev/index.html - - """ - needs = SphinxNeedsData(env).get_or_create_needs() - - # Used to store needs in the docs, which are needed again later - found_needs_nodes = [] - for node_need in doctree.findall(Need): - need_id = node_need.attributes["ids"][0] - found_needs_nodes.append(node_need) - need_data = needs[need_id] - - process_constraints(app, need_data) + process_constraints(app) # We call process_needextend here by our own, so that we are able # to give print_need_nodes the already found need_nodes. process_needextend(app, doctree, fromdocname) - print_need_nodes(app, doctree, fromdocname, found_needs_nodes) + print_need_nodes(app, doctree, fromdocname, list(doctree.findall(Need))) @profile("NEED_PRINT") diff --git a/sphinx_needs/need_constraints.py b/sphinx_needs/need_constraints.py index 0cee5bc2d..910526f22 100644 --- a/sphinx_needs/need_constraints.py +++ b/sphinx_needs/need_constraints.py @@ -1,86 +1,106 @@ +from typing import Dict + +import jinja2 from sphinx.application import Sphinx from sphinx_needs.api.exceptions import NeedsConstraintFailed, NeedsConstraintNotAllowed from sphinx_needs.config import NeedsSphinxConfig -from sphinx_needs.data import NeedsInfoType +from sphinx_needs.data import SphinxNeedsData from sphinx_needs.filter_common import filter_single_need from sphinx_needs.logging import get_logger logger = get_logger(__name__) -def process_constraints(app: Sphinx, need: NeedsInfoType) -> None: +def process_constraints(app: Sphinx) -> None: """Analyse constraints of a single need, and set corresponding fields on the need data item. """ - needs_config = NeedsSphinxConfig(app.config) + env = app.env + needs_config = NeedsSphinxConfig(env.config) config_constraints = needs_config.constraints - need_id = need["id"] - constraints = need["constraints"] - - # flag that is set to False if any check fails - need["constraints_passed"] = True - - for constraint in constraints: - try: - executable_constraints = config_constraints[constraint] - except KeyError: - # Note, this is already checked for in add_need - raise NeedsConstraintNotAllowed( - f"Constraint {constraint} of need id {need_id} is not allowed by config value 'needs_constraints'." - ) - - # name is check_0, check_1, ... - for name, cmd in executable_constraints.items(): - if name == "severity": - # special key, that is not a check - continue - - # compile constraint and check if need fulfils it - constraint_passed = filter_single_need(app, need, cmd) - - if constraint_passed: - need["constraints_results"].setdefault(constraint, {})[name] = True - else: - need["constraints_results"].setdefault(constraint, {})[name] = False - need["constraints_passed"] = False - - if "severity" not in executable_constraints: - raise NeedsConstraintFailed( - f"'severity' key not set for constraint {constraint!r} in config 'needs_constraints'" - ) - severity = executable_constraints["severity"] - if severity not in needs_config.constraint_failed_options: - raise NeedsConstraintFailed( - f"Severity {severity!r} not set in config 'needs_constraint_failed_options'" - ) - failed_options = needs_config.constraint_failed_options[severity] - - # log/except if needed - if "warn" in failed_options.get("on_fail", []): - logger.warning( - f"Constraint {cmd} for need {need_id} FAILED! severity: {severity} [needs.constraint]", - type="needs", - subtype="constraint", - color="red", - location=(need["docname"], need["lineno"]), - ) - if "break" in failed_options.get("on_fail", []): - raise NeedsConstraintFailed( - f"FAILED a breaking constraint: >> {cmd} << for need " - f"{need_id} FAILED! breaking build process" - ) - - # set styles - old_style = need["style"] - if old_style and len(old_style) > 0: - new_styles = "".join(", " + x for x in failed_options.get("style", [])) - else: - old_style = "" - new_styles = "".join(x + "," for x in failed_options.get("style", [])) + needs = SphinxNeedsData(env).get_or_create_needs() + workflow = SphinxNeedsData(env).get_or_create_workflow() + + if workflow["needs_constraints"]: + return + + workflow["needs_constraints"] = True + + error_templates_cache: Dict[str, jinja2.Template] = {} + + for need in needs.values(): + need_id = need["id"] + constraints = need["constraints"] + + # flag that is set to False if any check fails + need["constraints_passed"] = True + + for constraint in constraints: + try: + executable_constraints = config_constraints[constraint] + except KeyError: + # Note, this is already checked for in add_need + raise NeedsConstraintNotAllowed( + f"Constraint {constraint} of need id {need_id} is not allowed by config value 'needs_constraints'." + ) - if failed_options.get("force_style", False): - need["style"] = new_styles.strip(", ") + # name is check_0, check_1, ... + for name, cmd in executable_constraints.items(): + if name in ("severity", "error_message"): + # special keys, that are not a check + continue + + # compile constraint and check if need fulfils it + constraint_passed = filter_single_need(app, need, cmd) + + if constraint_passed: + need["constraints_results"].setdefault(constraint, {})[name] = True else: - constraint_failed_style = old_style + new_styles - need["style"] = constraint_failed_style + need["constraints_results"].setdefault(constraint, {})[name] = False + need["constraints_passed"] = False + + if "error_message" in executable_constraints: + msg = str(executable_constraints["error_message"]) + template = error_templates_cache.setdefault(msg, jinja2.Template(msg)) + need["constraints_error"] = template.render(**need) + + if "severity" not in executable_constraints: + raise NeedsConstraintFailed( + f"'severity' key not set for constraint {constraint!r} in config 'needs_constraints'" + ) + severity = executable_constraints["severity"] + if severity not in needs_config.constraint_failed_options: + raise NeedsConstraintFailed( + f"Severity {severity!r} not set in config 'needs_constraint_failed_options'" + ) + failed_options = needs_config.constraint_failed_options[severity] + + # log/except if needed + if "warn" in failed_options.get("on_fail", []): + logger.warning( + f"Constraint {cmd} for need {need_id} FAILED! severity: {severity} {need.get('constraints_error', '')} [needs.constraint]", + type="needs", + subtype="constraint", + color="red", + location=(need["docname"], need["lineno"]), + ) + if "break" in failed_options.get("on_fail", []): + raise NeedsConstraintFailed( + f"FAILED a breaking constraint: >> {cmd} << for need " + f"{need_id} FAILED! breaking build process" + ) + + # set styles + old_style = need["style"] + if old_style and len(old_style) > 0: + new_styles = "".join(", " + x for x in failed_options.get("style", [])) + else: + old_style = "" + new_styles = "".join(x + "," for x in failed_options.get("style", [])) + + if failed_options.get("force_style", False): + need["style"] = new_styles.strip(", ") + else: + constraint_failed_style = old_style + new_styles + need["style"] = constraint_failed_style diff --git a/tests/__snapshots__/test_external.ambr b/tests/__snapshots__/test_external.ambr index 95958c496..04d9a5e6c 100644 --- a/tests/__snapshots__/test_external.ambr +++ b/tests/__snapshots__/test_external.ambr @@ -17,7 +17,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -89,7 +89,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, diff --git a/tests/__snapshots__/test_import.ambr b/tests/__snapshots__/test_import.ambr index e741620a8..017f2081d 100644 --- a/tests/__snapshots__/test_import.ambr +++ b/tests/__snapshots__/test_import.ambr @@ -1103,7 +1103,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2178,7 +2178,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2261,7 +2261,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2336,7 +2336,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2411,7 +2411,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2489,7 +2489,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2563,7 +2563,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2638,7 +2638,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2714,7 +2714,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2790,7 +2790,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2871,7 +2871,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -2947,7 +2947,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -3030,7 +3030,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -3106,7 +3106,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -3179,7 +3179,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -3255,7 +3255,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, @@ -4673,7 +4673,7 @@ 'completion': '', 'constraints': list([ ]), - 'constraints_passed': None, + 'constraints_passed': True, 'constraints_results': dict({ }), 'content_id': None, diff --git a/tests/__snapshots__/test_need_constraints.ambr b/tests/__snapshots__/test_need_constraints.ambr new file mode 100644 index 000000000..8334cd384 --- /dev/null +++ b/tests/__snapshots__/test_need_constraints.ambr @@ -0,0 +1,611 @@ +# serializer version: 1 +# name: test_need_constraints[test_app0] + dict({ + 'current_version': '', + 'project': 'Python', + 'versions': dict({ + '': dict({ + 'filters': dict({ + }), + 'filters_amount': 0, + 'needs': dict({ + 'SECURITY_REQ': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + ]), + 'constraints_passed': True, + 'constraints_results': dict({ + }), + 'content_id': 'SECURITY_REQ', + 'created_at': '', + 'delete': None, + 'description': 'This is a requirement describing OPSEC processes.', + 'docname': 'index', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'test1', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'SECURITY_REQ', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': None, + 'style': None, + 'tags': list([ + ]), + 'target_id': 'SECURITY_REQ', + 'template': None, + 'title': 'test1', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + 'SP_109F4': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + 'critical', + ]), + 'constraints_passed': True, + 'constraints_results': dict({ + 'critical': dict({ + 'check_0': True, + }), + }), + 'content_id': 'SP_109F4', + 'created_at': '', + 'delete': None, + 'description': 'Example of a successful constraint.', + 'docname': 'index', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'test2', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'SP_109F4', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + 'SECURITY_REQ', + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': None, + 'style': None, + 'tags': list([ + 'critical', + ]), + 'target_id': 'SP_109F4', + 'template': None, + 'title': 'test2', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + 'SP_3EBFA': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + 'critical', + ]), + 'constraints_error': 'need SP_3EBFA does not fulfill CRITICAL constraint, because tags are []', + 'constraints_passed': False, + 'constraints_results': dict({ + 'critical': dict({ + 'check_0': False, + }), + }), + 'content_id': 'SP_3EBFA', + 'created_at': '', + 'delete': None, + 'description': 'Example of a failed constraint.', + 'docname': 'index', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'test3', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'SP_3EBFA', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': 'debug', + 'links': list([ + 'SECURITY_REQ', + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': None, + 'style': 'red_bar', + 'tags': list([ + ]), + 'target_id': 'SP_3EBFA', + 'template': None, + 'title': 'test3', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + 'SP_CA3FB': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + 'team', + ]), + 'constraints_passed': False, + 'constraints_results': dict({ + 'team': dict({ + 'check_0': False, + }), + }), + 'content_id': 'SP_CA3FB', + 'created_at': '', + 'delete': None, + 'description': 'Example of a failed constraint with medium severity. Note the style from :ref:`needs_constraint_failed_options`', + 'docname': 'index', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'FAIL_01', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'SP_CA3FB', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': None, + 'style': 'yellow_bar,', + 'tags': list([ + ]), + 'target_id': 'SP_CA3FB', + 'template': None, + 'title': 'FAIL_01', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + 'SP_TOO_001': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + ]), + 'constraints_passed': True, + 'constraints_results': dict({ + }), + 'content_id': 'SP_TOO_001', + 'created_at': '', + 'delete': None, + 'description': 'The Tool awesome shall have a command line interface.', + 'docname': 'index', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'Command line interface', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'SP_TOO_001', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': 'implemented', + 'style': None, + 'tags': list([ + 'test', + 'test2', + ]), + 'target_id': 'SP_TOO_001', + 'template': None, + 'title': 'Command line interface', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + 'SP_TOO_002': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + 'critical', + ]), + 'constraints_error': "need SP_TOO_002 does not fulfill CRITICAL constraint, because tags are ['hello', 'there']", + 'constraints_passed': False, + 'constraints_results': dict({ + 'critical': dict({ + 'check_0': False, + }), + }), + 'content_id': 'SP_TOO_002', + 'created_at': '', + 'delete': None, + 'description': 'asdf', + 'docname': 'index', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'CLI', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'SP_TOO_002', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': 'dev', + 'style': 'red_bar', + 'tags': list([ + 'hello', + 'there', + ]), + 'target_id': 'SP_TOO_002', + 'template': None, + 'title': 'CLI', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + 'TEST_STYLE': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + 'critical', + ]), + 'constraints_error': 'need TEST_STYLE does not fulfill CRITICAL constraint, because tags are []', + 'constraints_passed': False, + 'constraints_results': dict({ + 'critical': dict({ + 'check_0': False, + }), + }), + 'content_id': 'TEST_STYLE', + 'created_at': '', + 'delete': None, + 'description': '', + 'docname': 'style_test', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'CLI', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'TEST_STYLE', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': None, + 'style': 'red_bar', + 'tags': list([ + ]), + 'target_id': 'TEST_STYLE', + 'template': None, + 'title': 'CLI', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + 'TEST_STYLE2': dict({ + 'arch': dict({ + }), + 'avatar': '', + 'closed_at': '', + 'completion': '', + 'constraints': list([ + 'team', + ]), + 'constraints_passed': False, + 'constraints_results': dict({ + 'team': dict({ + 'check_0': False, + }), + }), + 'content_id': 'TEST_STYLE2', + 'created_at': '', + 'delete': None, + 'description': '', + 'docname': 'style_test', + 'doctype': '.rst', + 'duration': '', + 'external_css': 'external_link', + 'external_url': None, + 'full_title': 'CLI2', + 'has_dead_links': '', + 'has_forbidden_dead_links': '', + 'hidden': '', + 'id': 'TEST_STYLE2', + 'id_prefix': '', + 'is_external': False, + 'is_modified': False, + 'is_need': True, + 'is_part': False, + 'jinja_content': None, + 'layout': '', + 'links': list([ + ]), + 'max_amount': '', + 'max_content_lines': '', + 'modifications': 0, + 'params': '', + 'parent_need': '', + 'parent_needs': list([ + ]), + 'parent_needs_back': list([ + ]), + 'parts': dict({ + }), + 'post_template': None, + 'pre_template': None, + 'prefix': '', + 'query': '', + 'section_name': 'TEST DOCUMENT NEEDS CONSTRAINTS', + 'sections': list([ + 'TEST DOCUMENT NEEDS CONSTRAINTS', + ]), + 'service': '', + 'signature': '', + 'specific': '', + 'status': None, + 'style': 'blue_border, yellow_bar', + 'tags': list([ + ]), + 'target_id': 'TEST_STYLE2', + 'template': None, + 'title': 'CLI2', + 'type': 'spec', + 'type_name': 'Specification', + 'updated_at': '', + 'url': '', + 'url_postfix': '', + 'user': '', + }), + }), + 'needs_amount': 8, + }), + }), + }) +# --- diff --git a/tests/doc_test/need_constraints/conf.py b/tests/doc_test/need_constraints/conf.py index a2a50da0b..acbee6adf 100644 --- a/tests/doc_test/need_constraints/conf.py +++ b/tests/doc_test/need_constraints/conf.py @@ -2,6 +2,7 @@ extensions = ["sphinx_needs"] +needs_build_json = True needs_table_style = "TABLE" needs_types = [ @@ -54,11 +55,14 @@ def setup(app): needs_extra_options = [] - needs_constraints = { "security": {"check_0": "'security' in tags", "severity": "CRITICAL"}, "team": {"check_0": "'team_requirement' in links", "severity": "MEDIUM"}, - "critical": {"check_0": "'critical' in tags", "severity": "CRITICAL"}, + "critical": { + "check_0": "'critical' in tags", + "severity": "CRITICAL", + "error_message": "need {{id}} does not fulfill CRITICAL constraint, because tags are {{tags}}", + }, } diff --git a/tests/test_need_constraints.py b/tests/test_need_constraints.py index f55f2fe95..72e869dfa 100644 --- a/tests/test_need_constraints.py +++ b/tests/test_need_constraints.py @@ -1,16 +1,22 @@ +import json import subprocess from pathlib import Path import pytest +from syrupy.filters import props from sphinx_needs.api.exceptions import NeedsConstraintNotAllowed @pytest.mark.parametrize("test_app", [{"buildername": "html", "srcdir": "doc_test/need_constraints"}], indirect=True) -def test_need_constraints(test_app): +def test_need_constraints(test_app, snapshot): app = test_app app.build() + json_text = Path(app.outdir, "needs.json").read_text() + needs_data = json.loads(json_text) + assert needs_data == snapshot(exclude=props("created")) + srcdir = Path(app.srcdir) out_dir = srcdir / "_build"