Repository: useblocks/sphinx-needs
Pull request #1338 (2024-10-27 16:06:56)

✨ Allow configuring description of extra option
This PR allows for using a dict for 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?"},
]
```

---

Note, I envisage this extended dict format eventually also supporting other settings such as a data type, default, and possibly also limiting the need types it applies to, e.g.

```python
needs_extra_options = [
    {"name": "duration", 
     "description": "What is the impact of this need?",
     "type": "number",
     "default": 1,
     "need_types": ["req", "spec"]
    },
]
```

These though would require additional code changes

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1338?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) Report
Attention: Patch coverage is `92.30769%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 88.01%. Comparing base [(`4e10030`)](https://app.codecov.io/gh/useblocks/sphinx-needs/commit/4e10030581c03c59764e517ab750d3ddbb2695d8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) to head [(`3d439ec`)](https://app.codecov.io/gh/useblocks/sphinx-needs/commit/3d439ecf1364e03da8129fd649a27683a7951a0f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).
> Report is 79 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1338?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) | Patch % | Lines |
|---|---|---|
| [sphinx\_needs/needs.py](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1338?src=pr&el=tree&filepath=sphinx_needs%2Fneeds.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks#diff-c3BoaW54X25lZWRzL25lZWRzLnB5) | 87.50% | [1 Missing :warning: ](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1338      +/-   ##
==========================================
+ Coverage   86.87%   88.01%   +1.13%     
==========================================
  Files          56       60       +4     
  Lines        6532     7081     +549     
==========================================
+ Hits         5675     6232     +557     
+ Misses        857      849       -8     
```

| [Flag](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1338/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) | Coverage Δ | |
|---|---|---|
| [pytests](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1338/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) | `88.01% <92.30%> (+1.13%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1338?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).


Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index d699d107..02f5a116 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 <https://toml.io/en/v1.0.0#table>`__ to read from in the toml file, use the ``needs_from_toml_table`` option.
+To specify a different `root table path <https://toml.io/en/v1.0.0#table>`__ 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 <needs_builder_format>`,
+        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 1a852407..c1afa03f 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 @@ class NeedsSphinxConfig:
     """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 @@ class NeedsSphinxConfig:
         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 e54fcd2e..23577e10 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):


Test patch:
diff --git a/tests/__snapshots__/test_extra_options.ambr b/tests/__snapshots__/test_extra_options.ambr
new file mode 100644
index 00000000..e07ea2c3
--- /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 cb7c3c6a..52b0b468 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 4e692188..346395ec 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: <cite>1.0.0</cite>' in html