From 7a3e9efaa8fdf8940801488093b1fd686b0ffce9 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 28 Oct 2024 14:44:49 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Allow=20configuring=20description?= =?UTF-8?q?=20of=20extra=20option=20(#1338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit allows for using a dict to define a `needs_extra_options` item, that can have both a `name` and (optional) `description` key, e.g.: ```python needs_extra_options = [ "introduced", {"name": "impacts", "description": "What is the impact of this need?"}, ] ``` This also allows for future addition of more optional keys --- docs/configuration.rst | 22 +- sphinx_needs/config.py | 14 +- sphinx_needs/needs.py | 37 +- tests/__snapshots__/test_extra_options.ambr | 505 ++++++++++++++++++++ tests/doc_test/extra_options/conf.py | 13 +- tests/test_extra_options.py | 22 +- 6 files changed, 593 insertions(+), 20 deletions(-) create mode 100644 tests/__snapshots__/test_extra_options.ambr diff --git a/docs/configuration.rst b/docs/configuration.rst index d699d1079..02f5a116d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -113,12 +113,12 @@ For example: {directive="spec", title="Specification", prefix="S_", color="#FEDCD2", style="node"}, ] -To specify a different `table path `__ to read from in the toml file, use the ``needs_from_toml_table`` option. +To specify a different `root table path `__ to read from in the toml file, use the ``needs_from_toml_table`` option. For example to read from a ``[tool.needs]`` table: .. code-block:: python - needs_from_toml_table = ["tool", "needs"] + needs_from_toml_table = ["tool"] .. caution:: Any configuration specifying relative paths in the toml file will be resolved relative to the directory containing the :file:`conf.py` file. @@ -256,6 +256,24 @@ And use it like: .. needlist:: :filter: "filter_me" in another_option +.. versionadded:: 4.1.0 + + 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. + + For example: + + .. code-block:: python + + needs_extra_options = [ + "my_extra_option", + {"name": "my_other_option", "description": "This is a description of the option"} + ] + .. _needs_global_options: needs_global_options diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index 1a8524076..c1afa03f8 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -228,6 +228,14 @@ class NeedType(TypedDict): """The default node style to use in diagrams (default: "node").""" +class NeedExtraOption(TypedDict): + """Defines an extra option for needs""" + + name: str + description: NotRequired[str] + """A description of the option.""" + + @dataclass class NeedsSphinxConfig: """A wrapper around the Sphinx configuration, @@ -313,9 +321,9 @@ def get_default(cls, name: str) -> Any: """Path to a TOML file to load configuration from.""" from_toml_table: list[str] = field( - default_factory=lambda: ["needs"], metadata={"rebuild": "env", "types": (list,)} + default_factory=list, metadata={"rebuild": "env", "types": (list,)} ) - """Path to the table in the toml file to load configuration from.""" + """Path to the root table in the toml file to load configuration from.""" types: list[NeedType] = field( default_factory=lambda: [ @@ -407,7 +415,7 @@ def get_default(cls, name: str) -> Any: default=30, metadata={"rebuild": "html", "types": (int,)} ) """Maximum length of the title in the need role output.""" - _extra_options: list[str] = field( + _extra_options: list[str | NeedExtraOption] = field( default_factory=list, metadata={"rebuild": "html", "types": (list,)} ) """List of extra options for needs, that get added as directive options and need fields.""" diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index e54fcd2ee..23577e102 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -385,7 +385,7 @@ def load_config_from_toml(app: Sphinx, config: Config) -> None: try: with toml_file.open("rb") as f: toml_data = tomllib.load(f) - for key in toml_path: + for key in (*toml_path, "needs"): toml_data = toml_data[key] assert isinstance(toml_data, dict), "Data must be a dict" except Exception as e: @@ -411,15 +411,40 @@ def load_config(app: Sphinx, *_args: Any) -> None: needs_config = NeedsSphinxConfig(app.config) if isinstance(needs_config._extra_options, dict): - LOGGER.info( + log_warning( + LOGGER, 'Config option "needs_extra_options" supports list and dict. However new default type since ' - "Sphinx-Needs 0.7.2 is list. Please see docs for details." + "Sphinx-Needs 0.7.2 is list. Please see docs for details.", + "config", + None, ) for option in needs_config._extra_options: - _NEEDS_CONFIG.add_extra_option( - option, "Added by needs_extra_options config", override=True - ) + description = "Added by needs_extra_options config" + if isinstance(option, str): + name = option + elif isinstance(option, dict): + try: + name = option["name"] + except KeyError: + log_warning( + LOGGER, + f"extra_option is a dict, but does not contain a 'name' key: {option}", + "config", + None, + ) + continue + description = option.get("description", description) + else: + log_warning( + LOGGER, + f"extra_option is not a string or dict: {option}", + "config", + None, + ) + continue + + _NEEDS_CONFIG.add_extra_option(name, description, override=True) # ensure options for ``needgantt`` functionality are added to the extra options for option in (needs_config.duration_option, needs_config.completion_option): diff --git a/tests/__snapshots__/test_extra_options.ambr b/tests/__snapshots__/test_extra_options.ambr new file mode 100644 index 000000000..e07ea2c3b --- /dev/null +++ b/tests/__snapshots__/test_extra_options.ambr @@ -0,0 +1,505 @@ +# name: test_custom_attributes_appear[test_app0] + dict({ + 'current_version': '', + 'versions': dict({ + '': dict({ + 'needs': dict({ + 'R_12345': dict({ + 'content': 'The Tool **shall** have a command line interface.', + 'docname': 'index', + 'external_css': 'external_link', + 'full_title': 'Command line interface', + 'id': 'R_12345', + 'impacts': 'component_a', + 'introduced': '1.0.0', + 'layout': '', + 'section_name': 'Section 1', + 'sections': list([ + 'Section 1', + 'TEST DOCUMENT', + ]), + 'tags': list([ + 'test', + 'test2', + ]), + 'title': 'Command line interface', + 'type': 'req', + 'type_name': 'Requirement', + 'updated': '1.5.1', + }), + 'R_12346': dict({ + 'content': 'The Tool **shall** have a command line interface.', + 'docname': 'index', + 'external_css': 'external_link', + 'full_title': 'Another Requirement', + 'id': 'R_12346', + 'impacts': 'component_b', + 'introduced': '1.1.1', + 'layout': '', + 'section_name': 'Section 1', + 'sections': list([ + 'Section 1', + 'TEST DOCUMENT', + ]), + 'tags': list([ + 'test', + 'test2', + ]), + 'title': 'Another Requirement', + 'type': 'req', + 'type_name': 'Requirement', + 'updated': '1.4.0', + }), + }), + 'needs_amount': 2, + 'needs_defaults_removed': True, + 'needs_schema': dict({ + '$schema': 'http://json-schema.org/draft-07/schema#', + 'properties': dict({ + 'arch': dict({ + 'additionalProperties': dict({ + 'type': 'string', + }), + 'default': dict({ + }), + 'description': 'Mapping of uml key to uml content.', + 'field_type': 'core', + 'type': 'object', + }), + 'avatar': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'closed_at': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'completion': dict({ + 'default': '', + 'description': 'Added for needgantt functionality', + 'field_type': 'extra', + 'type': 'string', + }), + 'constraints': dict({ + 'default': list([ + ]), + 'description': 'List of constraint names, which are defined for this need.', + 'field_type': 'core', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'constraints_error': dict({ + 'default': '', + 'description': 'An error message set if any constraint failed, and `error_message` field is set in config.', + 'field_type': 'core', + 'type': 'string', + }), + 'constraints_passed': dict({ + 'default': True, + 'description': 'True if all constraints passed, False if any failed, None if not yet checked.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'constraints_results': dict({ + 'additionalProperties': dict({ + 'type': 'object', + }), + 'default': dict({ + }), + 'description': 'Mapping of constraint name, to check name, to result.', + 'field_type': 'core', + 'type': 'object', + }), + 'content': dict({ + 'default': '', + 'description': 'Content of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'created_at': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'delete': dict({ + 'default': False, + 'description': 'If true, the need is deleted entirely.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'docname': dict({ + 'default': None, + 'description': 'Name of the document where the need is defined (None if external).', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'doctype': dict({ + 'default': '.rst', + 'description': "Type of the document where the need is defined, e.g. '.rst'.", + 'field_type': 'core', + 'type': 'string', + }), + 'duration': dict({ + 'default': '', + 'description': 'Added for needgantt functionality', + 'field_type': 'extra', + 'type': 'string', + }), + 'external_css': dict({ + 'default': '', + 'description': 'CSS class name, added to the external reference.', + 'field_type': 'core', + 'type': 'string', + }), + 'external_url': dict({ + 'default': None, + 'description': 'URL of the need, if it is an external need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'full_title': dict({ + 'default': '', + 'description': 'Title of the need, of unlimited length.', + 'field_type': 'core', + 'type': 'string', + }), + 'has_dead_links': dict({ + 'default': False, + 'description': 'True if any links reference need ids that are not found in the need list.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'has_forbidden_dead_links': dict({ + 'default': False, + 'description': 'True if any links reference need ids that are not found in the need list, and the link type does not allow dead links.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'id': dict({ + 'description': 'ID of the data.', + 'field_type': 'core', + 'type': 'string', + }), + 'id_prefix': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'impacts': dict({ + 'default': '', + 'description': 'What is the impact of this need?', + 'field_type': 'extra', + 'type': 'string', + }), + 'introduced': dict({ + 'default': '', + 'description': 'Added by needs_extra_options config', + 'field_type': 'extra', + 'type': 'string', + }), + 'is_external': dict({ + 'default': False, + 'description': 'If true, no node is created and need is referencing external url.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'is_modified': dict({ + 'default': False, + 'description': 'Whether the need was modified by needextend.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'is_need': dict({ + 'default': True, + 'description': 'Whether the need is a need.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'is_part': dict({ + 'default': False, + 'description': 'Whether the need is a part.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'jinja_content': dict({ + 'default': False, + 'description': 'Whether the content should be pre-processed by jinja.', + 'field_type': 'core', + 'type': 'boolean', + }), + 'layout': dict({ + 'default': None, + 'description': 'Key of the layout, which is used to render the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'links': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'links_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'max_amount': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'max_content_lines': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'modifications': dict({ + 'default': 0, + 'description': 'Number of modifications by needextend.', + 'field_type': 'core', + 'type': 'integer', + }), + 'modified': dict({ + 'default': '', + 'description': 'When was this need last modified?', + 'field_type': 'extra', + 'type': 'string', + }), + 'params': dict({ + 'default': '', + 'description': 'Added by service open-needs', + 'field_type': 'extra', + 'type': 'string', + }), + 'parent_need': dict({ + 'default': '', + 'description': 'Simply the first parent id.', + 'field_type': 'core', + 'type': 'string', + }), + 'parent_needs': dict({ + 'default': list([ + ]), + 'description': 'Link field', + 'field_type': 'links', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'parent_needs_back': dict({ + 'default': list([ + ]), + 'description': 'Backlink field', + 'field_type': 'backlinks', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'parts': dict({ + 'additionalProperties': dict({ + 'type': 'object', + }), + 'default': dict({ + }), + 'description': "Mapping of parts, a.k.a. sub-needs, IDs to data that overrides the need's data", + 'field_type': 'core', + 'type': 'object', + }), + 'post_content': dict({ + 'default': '', + 'description': 'Post-content of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'post_template': dict({ + 'default': None, + 'description': 'Post-template of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'pre_content': dict({ + 'default': '', + 'description': 'Pre-content of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'pre_template': dict({ + 'default': None, + 'description': 'Pre-template of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'prefix': dict({ + 'default': '', + 'description': 'Added by service open-needs', + 'field_type': 'extra', + 'type': 'string', + }), + 'query': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'section_name': dict({ + 'default': '', + 'description': 'Simply the first section.', + 'field_type': 'core', + 'type': 'string', + }), + 'sections': dict({ + 'default': list([ + ]), + 'description': 'Sections of the need.', + 'field_type': 'core', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'service': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'signature': dict({ + 'default': '', + 'description': 'Derived from a docutils desc_name node.', + 'field_type': 'core', + 'type': 'string', + }), + 'specific': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'status': dict({ + 'default': None, + 'description': 'Status of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'style': dict({ + 'default': None, + 'description': 'Comma-separated list of CSS classes (all appended by `needs_style_`).', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'tags': dict({ + 'default': list([ + ]), + 'description': 'List of tags.', + 'field_type': 'core', + 'items': dict({ + 'type': 'string', + }), + 'type': 'array', + }), + 'template': dict({ + 'default': None, + 'description': 'Template of the need.', + 'field_type': 'core', + 'type': list([ + 'string', + 'null', + ]), + }), + 'title': dict({ + 'description': 'Title of the need, trimmed to a maximum length.', + 'field_type': 'core', + 'type': 'string', + }), + 'type': dict({ + 'default': '', + 'description': 'Type of the need.', + 'field_type': 'core', + 'type': 'string', + }), + 'type_name': dict({ + 'default': '', + 'description': 'Name of the type.', + 'field_type': 'core', + 'type': 'string', + }), + 'updated': dict({ + 'default': '', + 'description': 'Added by needs_extra_options config', + 'field_type': 'extra', + 'type': 'string', + }), + 'updated_at': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'url': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + 'url_postfix': dict({ + 'default': '', + 'description': 'Added by service open-needs', + 'field_type': 'extra', + 'type': 'string', + }), + 'user': dict({ + 'default': '', + 'description': 'Added by service github-issues', + 'field_type': 'extra', + 'type': 'string', + }), + }), + 'type': 'object', + }), + }), + }), + }) +# --- diff --git a/tests/doc_test/extra_options/conf.py b/tests/doc_test/extra_options/conf.py index cb7c3c6a6..52b0b468b 100644 --- a/tests/doc_test/extra_options/conf.py +++ b/tests/doc_test/extra_options/conf.py @@ -1,12 +1,23 @@ extensions = ["sphinx_needs"] -needs_extra_options = ["introduced", "updated", "impacts"] +needs_extra_options = [ + "introduced", + "updated", + {"name": "impacts", "description": "What is the impact of this need?"}, + {}, + 1, +] + +needs_build_json = True +needs_reproducible_json = True +needs_json_remove_defaults = True def setup(app): from sphinx_needs.api.configuration import add_extra_option add_extra_option(app, "introduced") + add_extra_option(app, "modified", description="When was this need last modified?") needs_template_collapse = """ diff --git a/tests/test_extra_options.py b/tests/test_extra_options.py index 4e6921887..346395ec1 100644 --- a/tests/test_extra_options.py +++ b/tests/test_extra_options.py @@ -1,26 +1,32 @@ +import json import re from pathlib import Path import pytest +from sphinx.util.console import strip_colors +from syrupy.filters import props @pytest.mark.parametrize( "test_app", - [{"buildername": "html", "srcdir": "doc_test/extra_options"}], + [{"buildername": "html", "srcdir": "doc_test/extra_options", "no_plantuml": True}], indirect=True, ) -def test_custom_attributes_appear(test_app): +def test_custom_attributes_appear(test_app, snapshot): app = test_app app.build() - html = Path(app.outdir, "index.html").read_text() + warnings = strip_colors(app._warning.getvalue()).splitlines() + assert warnings == [ + 'WARNING: extra_option "introduced" already registered. [needs.config]', + "WARNING: extra_option is a dict, but does not contain a 'name' key: {} [needs.config]", + "WARNING: extra_option is not a string or dict: 1 [needs.config]", + ] - warning = app._warning - # stdout warnings - warnings = warning.getvalue() + needs = json.loads(Path(app.outdir, "needs.json").read_text("utf8")) + assert needs == snapshot(exclude=props("created", "project", "creator")) - # Check for multiple registered names - assert 'extra_option "introduced" already registered.' in warnings + html = Path(app.outdir, "index.html").read_text() # Custom options should appear # assert 'introduced: 1.0.0' in html