Repository: useblocks/sphinx-needs
Pull request #1426 (2025-03-19 17:14:38)

🐛 Warn on dynamic function with surrounding text
Currently, for an item in a list field, with a dynamic function plus other text, the other text will be silently omitted, e.g.

```restructuredtext
.. spec:: TEST_1
    :id: TEST_1
    :tags: test_1a;test_1b;[[copy('title')]]omitted
```

A warning is now emitted about this

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1426?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 88.52%. 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 [(`12fb6c0`)](https://app.codecov.io/gh/useblocks/sphinx-needs/commit/12fb6c022cf96f074131dc013aa5d8c18ad2c6b8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).
> Report is 122 commits behind head on master.

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


```diff
@@            Coverage Diff             @@
##           master    #1426      +/-   ##
==========================================
+ Coverage   86.87%   88.52%   +1.64%     
==========================================
  Files          56       60       +4     
  Lines        6532     7236     +704     
==========================================
+ Hits         5675     6406     +731     
+ Misses        857      830      -27     
```

| [Flag](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1426/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/1426/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) | `88.52% <100.00%> (+1.64%)` | :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/1426?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).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/sphinx_needs/functions/functions.py b/sphinx_needs/functions/functions.py
index 614a9a7a..525421f7 100644
--- a/sphinx_needs/functions/functions.py
+++ b/sphinx_needs/functions/functions.py
@@ -318,6 +318,18 @@ def resolve_dynamic_values(needs: NeedsMutable, app: Sphinx) -> None:
                         new_values += func_return
                     else:
                         new_values += [func_return]
+                    if func_call is not None and not (
+                        str(element).strip().startswith("[[")
+                        and str(element).strip().endswith("]]")
+                    ):
+                        log_warning(
+                            logger,
+                            f"Dynamic function in list field {need_option!r} is surrounded by text that will be omitted: {element!r}",
+                            "dynamic_function",
+                            location=(need["docname"], need["lineno"])
+                            if need.get("docname")
+                            else None,
+                        )
 
                 need[need_option] = new_values
 


Test patch:
diff --git a/tests/__snapshots__/test_dynamic_functions.ambr b/tests/__snapshots__/test_dynamic_functions.ambr
new file mode 100644
index 00000000..40316edb
--- /dev/null
+++ b/tests/__snapshots__/test_dynamic_functions.ambr
@@ -0,0 +1,576 @@
+# serializer version: 1
+# name: test_doc_dynamic_functions[test_app0]
+  dict({
+    'current_version': '',
+    'versions': dict({
+      '': dict({
+        'needs': dict({
+          'SP_TOO_001': dict({
+            'content': '''
+              This is id [[copy("id")]]
+              
+              This is also id :need_func:`[[copy("id")]]`
+              
+              This is the best id :ndf:`copy("id")`
+            ''',
+            'docname': 'index',
+            'external_css': 'external_link',
+            'id': 'SP_TOO_001',
+            'layout': '',
+            'lineno': 4,
+            'section_name': 'DYNAMIC FUNCTIONS',
+            'sections': list([
+              'DYNAMIC FUNCTIONS',
+            ]),
+            'status': 'implemented',
+            'tags': list([
+              'test',
+              'test2',
+            ]),
+            'title': 'Command line interface',
+            'type': 'spec',
+            'type_name': 'Specification',
+          }),
+          'TEST_2': dict({
+            'docname': 'index',
+            'external_css': 'external_link',
+            'id': 'TEST_2',
+            'layout': '',
+            'lineno': 15,
+            'section_name': 'DYNAMIC FUNCTIONS',
+            'sections': list([
+              'DYNAMIC FUNCTIONS',
+            ]),
+            'tags': list([
+              'my_tag',
+              'test',
+              'test2',
+            ]),
+            'title': 'TEST_2',
+            'type': 'spec',
+            'type_name': 'Specification',
+          }),
+          'TEST_3': dict({
+            'docname': 'index',
+            'external_css': 'external_link',
+            'id': 'TEST_3',
+            'layout': '',
+            'lineno': 19,
+            'section_name': 'DYNAMIC FUNCTIONS',
+            'sections': list([
+              'DYNAMIC FUNCTIONS',
+            ]),
+            'test_func': 'Test output of dynamic function; need: TEST_3; args: (); kwargs: {}',
+            'title': 'TEST_3',
+            'type': 'spec',
+            'type_name': 'Specification',
+          }),
+          'TEST_4': dict({
+            'content': 'Test dynamic func in tags: [[copy("tags")]]',
+            'docname': 'index',
+            'external_css': 'external_link',
+            'id': 'TEST_4',
+            'layout': '',
+            'lineno': 23,
+            'section_name': 'DYNAMIC FUNCTIONS',
+            'sections': list([
+              'DYNAMIC FUNCTIONS',
+            ]),
+            'tags': list([
+              'test_4a',
+              'test_4b',
+              'TEST_4',
+            ]),
+            'title': 'TEST_4',
+            'type': 'spec',
+            'type_name': 'Specification',
+          }),
+          'TEST_5': dict({
+            'content': '''
+              Test a `link <http://www.[[copy('id')]]>`_
+              
+              .. spec:: TEST_6
+                  :id: TEST_6
+              
+                  nested id [[copy('id')]]
+              
+                  nested id also :need_func:`[[copy("id")]]`
+              
+                  nested id best :ndf:`copy("id")`
+            ''',
+            'docname': 'index',
+            'external_css': 'external_link',
+            'id': 'TEST_5',
+            'layout': '',
+            'lineno': 29,
+            'parent_needs_back': list([
+              'TEST_6',
+            ]),
+            'section_name': 'DYNAMIC FUNCTIONS',
+            'sections': list([
+              'DYNAMIC FUNCTIONS',
+            ]),
+            'tags': list([
+              'TEST_5',
+            ]),
+            'title': 'TEST_5',
+            'type': 'spec',
+            'type_name': 'Specification',
+          }),
+          'TEST_6': dict({
+            'content': '''
+              nested id [[copy('id')]]
+              
+              nested id also :need_func:`[[copy("id")]]`
+              
+              nested id best :ndf:`copy("id")`
+            ''',
+            'docname': 'index',
+            'external_css': 'external_link',
+            'id': 'TEST_6',
+            'layout': '',
+            'lineno': 35,
+            'parent_need': 'TEST_5',
+            'parent_needs': list([
+              'TEST_5',
+            ]),
+            'section_name': 'DYNAMIC FUNCTIONS',
+            'sections': list([
+              'DYNAMIC FUNCTIONS',
+            ]),
+            'title': 'TEST_6',
+            'type': 'spec',
+            'type_name': 'Specification',
+          }),
+        }),
+        'needs_amount': 6,
+        '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',
+            }),
+            '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',
+              ]),
+            }),
+            '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',
+            }),
+            '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',
+              ]),
+            }),
+            'lineno': dict({
+              'default': None,
+              'description': 'Line number where the need is defined (None if external).',
+              'field_type': 'core',
+              'type': list([
+                'integer',
+                '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',
+            }),
+            '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',
+              ]),
+            }),
+            'test_func': dict({
+              'default': '',
+              'description': 'Added by needs_extra_options config',
+              'field_type': 'extra',
+              'type': 'string',
+            }),
+            'title': dict({
+              'description': 'Title of the need.',
+              '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_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/doc_dynamic_functions/conf.py b/tests/doc_test/doc_dynamic_functions/conf.py
index 111e1eab..441401de 100644
--- a/tests/doc_test/doc_dynamic_functions/conf.py
+++ b/tests/doc_test/doc_dynamic_functions/conf.py
@@ -32,3 +32,6 @@ needs_types = [
 ]
 
 needs_extra_options = ["test_func"]
+
+needs_build_json = True
+needs_json_remove_defaults = True
diff --git a/tests/doc_test/doc_dynamic_functions/index.rst b/tests/doc_test/doc_dynamic_functions/index.rst
index 85d90e2a..f33e8638 100644
--- a/tests/doc_test/doc_dynamic_functions/index.rst
+++ b/tests/doc_test/doc_dynamic_functions/index.rst
@@ -22,7 +22,7 @@ DYNAMIC FUNCTIONS
 
 .. spec:: TEST_4
     :id: TEST_4
-    :tags: test_4a;test_4b;[[copy('title')]]
+    :tags: test_4a;test_4b;[[copy('title')]]omitted
 
     Test dynamic func in tags: [[copy("tags")]]
 
diff --git a/tests/test_dynamic_functions.py b/tests/test_dynamic_functions.py
index 4d1227ca..7d5e7981 100644
--- a/tests/test_dynamic_functions.py
+++ b/tests/test_dynamic_functions.py
@@ -1,10 +1,11 @@
+import json
 import os
-import re
 from pathlib import Path
 
 import pytest
 from sphinx import version_info
 from sphinx.util.console import strip_colors
+from syrupy.filters import props
 
 
 @pytest.mark.parametrize(
@@ -18,7 +19,7 @@ from sphinx.util.console import strip_colors
     ],
     indirect=True,
 )
-def test_doc_dynamic_functions(test_app):
+def test_doc_dynamic_functions(test_app, snapshot):
     app = test_app
     app.build()
 
@@ -29,6 +30,7 @@ def test_doc_dynamic_functions(test_app):
         'srcdir/index.rst:11: WARNING: The `need_func` role is deprecated. Replace with :ndf:`copy("id")` instead. [needs.deprecated]',
         'srcdir/index.rst:40: WARNING: The `need_func` role is deprecated. Replace with :ndf:`copy("id")` instead. [needs.deprecated]',
         'srcdir/index.rst:44: WARNING: The `need_func` role is deprecated. Replace with :ndf:`copy("id")` instead. [needs.deprecated]',
+        "srcdir/index.rst:23: WARNING: Dynamic function in list field 'tags' is surrounded by text that will be omitted: \"[[copy('title')]]omitted\" [needs.dynamic_function]",
         'srcdir/index.rst:9: WARNING: The [[copy("id")]] syntax in need content is deprecated. Replace with :ndf:`copy("id")` instead. [needs.deprecated]',
         'srcdir/index.rst:27: WARNING: The [[copy("tags")]] syntax in need content is deprecated. Replace with :ndf:`copy("tags")` instead. [needs.deprecated]',
         "srcdir/index.rst:33: WARNING: The [[copy('id')]] syntax in need content is deprecated. Replace with :ndf:`copy('id')` instead. [needs.deprecated]",
@@ -37,46 +39,9 @@ def test_doc_dynamic_functions(test_app):
         "srcdir/index.rst:44: WARNING: Error while executing function 'copy': Need not found [needs.dynamic_function]",
     ]
 
-    html = Path(app.outdir, "index.html").read_text()
-    assert "This is id SP_TOO_001" in html
-    assert "This is also id SP_TOO_001" in html
-    assert "This is the best id SP_TOO_001" in html
-
-    assert (
-        sum(1 for _ in re.finditer('<span class="needs_data">test2</span>', html)) == 2
-    )
-    assert (
-        sum(1 for _ in re.finditer('<span class="needs_data">test</span>', html)) == 2
-    )
-    assert (
-        sum(1 for _ in re.finditer('<span class="needs_data">my_tag</span>', html)) == 1
-    )
-
-    assert (
-        sum(1 for _ in re.finditer('<span class="needs_data">test_4a</span>', html))
-        == 1
-    )
-    assert (
-        sum(1 for _ in re.finditer('<span class="needs_data">test_4b</span>', html))
-        == 1
-    )
-    assert (
-        sum(1 for _ in re.finditer('<span class="needs_data">TEST_4</span>', html)) == 2
-    )
-
-    assert (
-        sum(1 for _ in re.finditer('<span class="needs_data">TEST_5</span>', html)) == 2
-    )
-
-    assert "Test output of dynamic function; need: TEST_3" in html
-
-    assert "Test dynamic func in tags: test_4a, test_4b, TEST_4" in html
-
-    assert '<a class="reference external" href="http://www.TEST_5">link</a>' in html
-
-    assert "nested id TEST_6" in html
-    assert "nested id also TEST_6" in html
-    assert "nested id best TEST_6" in html
+    json_data = Path(app.outdir, "needs.json").read_text()
+    needs = json.loads(json_data)
+    assert needs == snapshot(exclude=props("created", "project", "creator"))
 
 
 @pytest.mark.parametrize(