Repository: useblocks/sphinx-needs
Pull request #1274 (2024-09-04 18:14:56)

🐛 Pass `needs` to `highlight` filter of `graphviz` `needflow`
In a minor oversight from #1235, I realised that I did not pass the `needs` list to the `highlight` filter, as is the case for the `plantuml` engine.

I also took the opportunity to improve `_plantuml`, by passing the `NeedsView` to the sub-functions, rather than just the values.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1274?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 86.70%. 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 [(`6d5595f`)](https://app.codecov.io/gh/useblocks/sphinx-needs/commit/6d5595fcbfe29c85e65f36e02ff6d2979b82c27d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).
> Report is 31 commits behind head on master.

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


```diff
@@            Coverage Diff             @@
##           master    #1274      +/-   ##
==========================================
- Coverage   86.87%   86.70%   -0.18%     
==========================================
  Files          56       60       +4     
  Lines        6532     6927     +395     
==========================================
+ Hits         5675     6006     +331     
- Misses        857      921      +64     
```

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

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/1274?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/sphinx_needs/directives/needflow/_graphviz.py b/sphinx_needs/directives/needflow/_graphviz.py
index 5868d283..1d0ad9d8 100644
--- a/sphinx_needs/directives/needflow/_graphviz.py
+++ b/sphinx_needs/directives/needflow/_graphviz.py
@@ -15,7 +15,7 @@ from sphinx.ext.graphviz import (
 from sphinx.util.logging import getLogger
 
 from sphinx_needs.config import LinkOptionsType, NeedsSphinxConfig
-from sphinx_needs.data import NeedsInfoType, SphinxNeedsData
+from sphinx_needs.data import NeedsInfoType, NeedsView, SphinxNeedsData
 from sphinx_needs.debug import measure_time
 from sphinx_needs.diagrams_common import calculate_link
 from sphinx_needs.directives.needflow._directive import NeedflowGraphiz
@@ -49,7 +49,7 @@ def process_needflow_graphviz(
 ) -> None:
     needs_config = NeedsSphinxConfig(app.config)
     env_data = SphinxNeedsData(app.env)
-    all_needs = env_data.get_needs_view()
+    needs_view = env_data.get_needs_view()
 
     link_type_names = [link["option"].upper() for link in needs_config.extra_links]
     allowed_link_types_options = [link.upper() for link in needs_config.flow_link_types]
@@ -111,14 +111,14 @@ def process_needflow_graphviz(
 
         init_filtered_needs = (
             filter_by_tree(
-                all_needs,
+                needs_view,
                 root_id,
                 allowed_link_types,
                 attributes["root_direction"],
                 attributes["root_depth"],
             )
             if (root_id := attributes["root_id"])
-            else all_needs
+            else needs_view
         )
         filtered_needs = process_filters(
             app,
@@ -156,6 +156,7 @@ def process_needflow_graphviz(
             content += _render_node(
                 root_need,
                 node,
+                needs_view,
                 needs_config,
                 lambda n: calculate_link(app, n, fromdocname, relative="."),
                 id_comp_to_need,
@@ -202,6 +203,7 @@ def _quote(text: str) -> str:
 def _render_node(
     need: NeedsInfoType,
     node: NeedflowGraphiz,
+    needs_view: NeedsView,
     config: NeedsSphinxConfig,
     calc_link: Callable[[NeedsInfoType], str],
     id_comp_to_need: dict[str, NeedsInfoType],
@@ -220,7 +222,7 @@ def _render_node(
         # graphviz cannot nest nodes,
         # so we have to create a subgraph to represent a need with parts/children
         return _render_subgraph(
-            need, node, config, calc_link, id_comp_to_need, rendered_nodes
+            need, node, needs_view, config, calc_link, id_comp_to_need, rendered_nodes
         )
 
     rendered_nodes[need["id_complete"]] = {"need": need, "cluster_id": None}
@@ -258,7 +260,9 @@ def _render_node(
         params.append(("fillcolor", _quote(need["type_color"])))
 
     # outline color
-    if node["highlight"] and filter_single_need(need, config, node["highlight"]):
+    if node["highlight"] and filter_single_need(
+        need, config, node["highlight"], needs_view.values()
+    ):
         params.append(("color", "red"))
     elif node["border_color"]:
         color = match_variants(
@@ -278,6 +282,7 @@ def _render_node(
 def _render_subgraph(
     need: NeedsInfoType,
     node: NeedflowGraphiz,
+    needs_view: NeedsView,
     config: NeedsSphinxConfig,
     calc_link: Callable[[NeedsInfoType], str],
     id_comp_to_need: dict[str, NeedsInfoType],
@@ -340,6 +345,7 @@ def _render_subgraph(
                     _render_node(
                         id_comp_to_need[need_part_id],
                         node,
+                        needs_view,
                         config,
                         calc_link,
                         id_comp_to_need,
@@ -356,6 +362,7 @@ def _render_subgraph(
                     _render_node(
                         id_comp_to_need[child_need_id],
                         node,
+                        needs_view,
                         config,
                         calc_link,
                         id_comp_to_need,
@@ -369,8 +376,11 @@ def _render_subgraph(
 
 def _label(need: NeedsInfoType, align: Literal["left", "right", "center"]) -> str:
     """Create the graphviz label for a need."""
+    # note this is based on the plantuml template DEFAULT_DIAGRAM_TEMPLATE
+
     br = f'<br align="{align}"/>'
     # note this text wrapping mimics the jinja wordwrap filter
+    need_title = need["title"] if need["is_need"] else need["content"]
     title = br.join(
         br.join(
             textwrap.wrap(
@@ -382,13 +392,13 @@ def _label(need: NeedsInfoType, align: Literal["left", "right", "center"]) -> st
                 break_on_hyphens=True,
             )
         )
-        for line in need["title"].splitlines()
+        for line in need_title.splitlines()
     )
-    name = html.escape(need["type_name"])
+    name = html.escape(need["type_name"] + (" (part)" if need["is_part"] else ""))
     if need["is_need"]:
         _id = html.escape(need["id"])
     else:
-        _id = f"{html.escape(need['id_parent'])}.<b>{html.escape(need['id'])}</b>"
+        _id = f'{html.escape(need["id_parent"])}.<b align="{align}">{html.escape(need["id"])}</b>'
     font_10 = '<font point-size="10">'
     font_12 = '<font point-size="12">'
     return f"<{font_12}{name}</font>{br}<b>{title}</b>{br}{font_10}{_id}</font>{br}>"
diff --git a/sphinx_needs/directives/needflow/_plantuml.py b/sphinx_needs/directives/needflow/_plantuml.py
index cf261d46..0df5f1f5 100644
--- a/sphinx_needs/directives/needflow/_plantuml.py
+++ b/sphinx_needs/directives/needflow/_plantuml.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import html
 import os
 from functools import lru_cache
-from typing import Iterable
 
 from docutils import nodes
 from jinja2 import Template
@@ -16,6 +15,7 @@ from sphinx_needs.config import LinkOptionsType, NeedsSphinxConfig
 from sphinx_needs.data import (
     NeedsFlowType,
     NeedsInfoType,
+    NeedsView,
     SphinxNeedsData,
 )
 from sphinx_needs.debug import measure_time
@@ -46,7 +46,7 @@ def get_need_node_rep_for_plantuml(
     app: Sphinx,
     fromdocname: str,
     current_needflow: NeedsFlowType,
-    all_needs: Iterable[NeedsInfoType],
+    needs_view: NeedsView,
     need_info: NeedsInfoType,
 ) -> str:
     """Calculate need node representation for plantuml."""
@@ -63,7 +63,7 @@ def get_need_node_rep_for_plantuml(
         node_colors.append(need_info["type_color"].replace("#", ""))
 
     if current_needflow["highlight"] and filter_single_need(
-        need_info, needs_config, current_needflow["highlight"], all_needs
+        need_info, needs_config, current_needflow["highlight"], needs_view.values()
     ):
         node_colors.append("line:FF0000")
 
@@ -98,7 +98,7 @@ def walk_curr_need_tree(
     app: Sphinx,
     fromdocname: str,
     current_needflow: NeedsFlowType,
-    all_needs: Iterable[NeedsInfoType],
+    needs_view: NeedsView,
     found_needs: list[NeedsInfoType],
     need: NeedsInfoType,
 ) -> str:
@@ -125,7 +125,7 @@ def walk_curr_need_tree(
                 if need_part_id == found_need["id_complete"]:
                     curr_need_tree += (
                         get_need_node_rep_for_plantuml(
-                            app, fromdocname, current_needflow, all_needs, found_need
+                            app, fromdocname, current_needflow, needs_view, found_need
                         )
                         + "\n"
                     )
@@ -140,7 +140,7 @@ def walk_curr_need_tree(
             for curr_child_need in found_needs:
                 if curr_child_need["id_complete"] == curr_child_need_id:
                     curr_need_tree += get_need_node_rep_for_plantuml(
-                        app, fromdocname, current_needflow, all_needs, curr_child_need
+                        app, fromdocname, current_needflow, needs_view, curr_child_need
                     )
                     # check curr need child has children or has parts
                     if curr_child_need["parent_needs_back"] or curr_child_need["parts"]:
@@ -148,7 +148,7 @@ def walk_curr_need_tree(
                             app,
                             fromdocname,
                             current_needflow,
-                            all_needs,
+                            needs_view,
                             found_needs,
                             curr_child_need,
                         )
@@ -166,7 +166,7 @@ def cal_needs_node(
     app: Sphinx,
     fromdocname: str,
     current_needflow: NeedsFlowType,
-    all_needs: Iterable[NeedsInfoType],
+    needs_view: NeedsView,
     found_needs: list[NeedsInfoType],
 ) -> str:
     """Calculate and get needs node representaion for plantuml including all child needs and need parts."""
@@ -174,7 +174,7 @@ def cal_needs_node(
     curr_need_tree = ""
     for top_need in top_needs:
         top_need_node = get_need_node_rep_for_plantuml(
-            app, fromdocname, current_needflow, all_needs, top_need
+            app, fromdocname, current_needflow, needs_view, top_need
         )
         curr_need_tree += (
             top_need_node
@@ -182,7 +182,7 @@ def cal_needs_node(
                 app,
                 fromdocname,
                 current_needflow,
-                all_needs,
+                needs_view,
                 found_needs,
                 top_need,
             )
@@ -203,7 +203,7 @@ def process_needflow_plantuml(
     env = app.env
     needs_config = NeedsSphinxConfig(app.config)
     env_data = SphinxNeedsData(env)
-    all_needs = env_data.get_needs_view()
+    needs_view = env_data.get_needs_view()
 
     link_type_names = [link["option"].upper() for link in needs_config.extra_links]
     allowed_link_types_options = [link.upper() for link in needs_config.flow_link_types]
@@ -265,14 +265,14 @@ def process_needflow_plantuml(
 
         need_values = (
             filter_by_tree(
-                all_needs,
+                needs_view,
                 root_id,
                 allowed_link_types,
                 current_needflow["root_direction"],
                 current_needflow["root_depth"],
             )
             if (root_id := current_needflow.get("root_id"))
-            else all_needs
+            else needs_view
         )
 
         found_needs = process_filters(
@@ -310,7 +310,7 @@ def process_needflow_plantuml(
 
             puml_node["uml"] += "\n' Nodes definition \n\n"
             puml_node["uml"] += cal_needs_node(
-                app, fromdocname, current_needflow, all_needs.values(), found_needs
+                app, fromdocname, current_needflow, needs_view, found_needs
             )
 
             puml_node["uml"] += "\n' Connection definition \n\n"
diff --git a/sphinx_needs/functions/common.py b/sphinx_needs/functions/common.py
index 6e2b6884..a047adef 100644
--- a/sphinx_needs/functions/common.py
+++ b/sphinx_needs/functions/common.py
@@ -22,7 +22,7 @@ from sphinx_needs.utils import logger
 
 def test(
     app: Sphinx,
-    need: NeedsInfoType,
+    need: NeedsInfoType | None,
     needs: NeedsView,
     *args: Any,
     **kwargs: Any,
@@ -44,12 +44,13 @@ def test(
 
     :return: single test string
     """
-    return f"Test output of need {need['id']}. args: {args}. kwargs: {kwargs}"
+    need_id = "none" if need is None else need["id"]
+    return f"Test output of need_func; need: {need_id}; args: {args}; kwargs: {kwargs}"
 
 
 def echo(
     app: Sphinx,
-    need: NeedsInfoType,
+    need: NeedsInfoType | None,
     needs: NeedsView,
     text: str,
     *args: Any,
@@ -73,7 +74,7 @@ def echo(
 
 def copy(
     app: Sphinx,
-    need: NeedsInfoType,
+    need: NeedsInfoType | None,
     needs: NeedsView,
     option: str,
     need_id: str | None = None,
@@ -139,7 +140,7 @@ def copy(
         .. test:: test of current_need value
            :id: copy_4
 
-           The es the title of the first need found under the same highest
+           The following copy command copies the title of the first need found under the same  highest
            section (headline):
 
            [[copy('title', filter='current_need["sections"][-1]==sections[-1]')]]
@@ -150,7 +151,7 @@ def copy(
        The following copy command copies the title of the first need found under the same  highest
        section (headline):
 
-            [[copy('title', filter='current_need["sections"][-1]==sections[-1]')]]
+       [[copy('title', filter='current_need["sections"][-1]==sections[-1]')]]
 
     This filter possibilities get really powerful in combination with :ref:`needs_global_options`.
 
@@ -171,26 +172,32 @@ def copy(
             NeedsSphinxConfig(app.config),
             filter,
             need,
-            location=(need["docname"], need["lineno"]) if need["docname"] else None,
+            location=(need["docname"], need["lineno"])
+            if need and need["docname"]
+            else None,
         )
         if result:
             need = result[0]
 
-    value = need[option]  # type: ignore[literal-required]
+    if need is None:
+        raise ValueError("Need not found")
+
+    if option not in need:
+        raise ValueError(f"Option {option} not found in need {need['id']}")
 
-    # TODO check if str?
+    value = need[option]  # type: ignore[literal-required]
 
     if lower:
-        return value.lower()
+        return str(value).lower()
     if upper:
-        return value.upper()
+        return str(value).upper()
 
     return value
 
 
 def check_linked_values(
     app: Sphinx,
-    need: NeedsInfoType,
+    need: NeedsInfoType | None,
     needs: NeedsView,
     result: Any,
     search_option: str,
@@ -329,6 +336,9 @@ def check_linked_values(
     :param one_hit: If True, only one linked need must have a positive check
     :return: result, if all checks are positive
     """
+    if need is None:
+        raise ValueError("No need given for check_linked_values")
+
     needs_config = NeedsSphinxConfig(app.config)
     links = need["links"]
     if not isinstance(search_value, list):
@@ -359,7 +369,7 @@ def check_linked_values(
 
 def calc_sum(
     app: Sphinx,
-    need: NeedsInfoType,
+    need: NeedsInfoType | None,
     needs: NeedsView,
     option: str,
     filter: str | None = None,
@@ -444,6 +454,9 @@ def calc_sum(
 
     :return: A float number
     """
+    if need is None:
+        raise ValueError("No need given for check_linked_values")
+
     needs_config = NeedsSphinxConfig(app.config)
     check_needs = (
         [needs[link] for link in need["links"]] if links_only else needs.values()
@@ -471,7 +484,7 @@ def calc_sum(
 
 def links_from_content(
     app: Sphinx,
-    need: NeedsInfoType,
+    need: NeedsInfoType | None,
     needs: NeedsView,
     need_id: str | None = None,
     filter: str | None = None,
@@ -529,6 +542,9 @@ def links_from_content(
     """
     source_need = needs[need_id] if need_id else need
 
+    if source_need is None:
+        raise ValueError("No need found for links_from_content")
+
     links = re.findall(r":need:`(\w+)`|:need:`.+\<(.+)\>`", source_need["content"])
     raw_links = []
     for link in links:
diff --git a/sphinx_needs/functions/functions.py b/sphinx_needs/functions/functions.py
index 34c1cd3e..38926a40 100644
--- a/sphinx_needs/functions/functions.py
+++ b/sphinx_needs/functions/functions.py
@@ -23,6 +23,7 @@ from sphinx_needs.data import NeedsInfoType, NeedsMutable, NeedsView, SphinxNeed
 from sphinx_needs.debug import measure_time_func
 from sphinx_needs.logging import get_logger, log_warning
 from sphinx_needs.nodes import Need
+from sphinx_needs.roles.need_func import NeedFunc
 from sphinx_needs.utils import NEEDS_FUNCTIONS, match_variants
 
 logger = get_logger(__name__)
@@ -38,7 +39,7 @@ class DynamicFunction(Protocol):
     def __call__(
         self,
         app: Sphinx,
-        need: NeedsInfoType,
+        need: NeedsInfoType | None,
         needs: NeedsView,
         *args: Any,
         **kwargs: Any,
@@ -76,7 +77,7 @@ def register_func(need_function: DynamicFunction, name: str | None = None) -> No
 
 def execute_func(
     app: Sphinx,
-    need: NeedsInfoType,
+    need: NeedsInfoType | None,
     func_string: str,
     location: str | tuple[str | None, int | None] | nodes.Node | None,
 ) -> str | int | float | list[str] | list[int] | list[float] | None:
@@ -112,13 +113,23 @@ def execute_func(
     func = measure_time_func(
         NEEDS_FUNCTIONS[func_name]["function"], category="dyn_func", source="user"
     )
-    func_return = func(
-        app,
-        need,
-        SphinxNeedsData(app.env).get_needs_view(),
-        *func_args,
-        **func_kwargs,
-    )
+
+    try:
+        func_return = func(
+            app,
+            need,
+            SphinxNeedsData(app.env).get_needs_view(),
+            *func_args,
+            **func_kwargs,
+        )
+    except Exception as e:
+        log_warning(
+            logger,
+            f"Error while executing function {func_name!r}: {e}",
+            "dynamic_function",
+            location=location,
+        )
+        return "??"
 
     if func_return is not None and not isinstance(func_return, (str, int, float, list)):
         log_warning(
@@ -152,10 +163,11 @@ def find_and_replace_node_content(
     if found, check if it contains a function string and run/replace it.
 
     :param node: Node to analyse
-    :return: None
     """
     new_children = []
-    if (
+    if isinstance(node, NeedFunc):
+        return node.get_text(env, need)
+    elif (
         not node.children
         and isinstance(node, nodes.Text)
         or isinstance(node, nodes.reference)
@@ -360,7 +372,10 @@ def resolve_variants_options(
 
 
 def check_and_get_content(
-    content: str, need: NeedsInfoType, env: BuildEnvironment, location: nodes.Node
+    content: str,
+    need: NeedsInfoType | None,
+    env: BuildEnvironment,
+    location: nodes.Node,
 ) -> str:
     """
     Checks if the given content is a function call.
@@ -373,12 +388,6 @@ def check_and_get_content(
     :param location: source location of the function call
     :return: string
     """
-
-    try:
-        content = str(content)
-    except UnicodeEncodeError:
-        content = content.encode("utf-8")  # type: ignore
-
     func_match = func_pattern.search(content)
     if func_match is None:
         return content
diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py
index 116a5297..930c8732 100644
--- a/sphinx_needs/needs.py
+++ b/sphinx_needs/needs.py
@@ -100,7 +100,7 @@ from sphinx_needs.functions import NEEDS_COMMON_FUNCTIONS, register_func
 from sphinx_needs.logging import get_logger, log_warning
 from sphinx_needs.roles import NeedsXRefRole
 from sphinx_needs.roles.need_count import NeedCount, process_need_count
-from sphinx_needs.roles.need_func import NeedFunc, process_need_func
+from sphinx_needs.roles.need_func import NeedFunc, NeedFuncRole, process_need_func
 from sphinx_needs.roles.need_incoming import NeedIncoming, process_need_incoming
 from sphinx_needs.roles.need_outgoing import NeedOutgoing, process_need_outgoing
 from sphinx_needs.roles.need_part import NeedPart, process_need_part
@@ -253,12 +253,7 @@ def setup(app: Sphinx) -> dict[str, Any]:
         ),
     )
 
-    app.add_role(
-        "need_func",
-        NeedsXRefRole(
-            nodeclass=NeedFunc, innernodeclass=nodes.inline, warn_dangling=True
-        ),
-    )
+    app.add_role("need_func", NeedFuncRole())
 
     ########################################################################
     # EVENTS
diff --git a/sphinx_needs/roles/need_func.py b/sphinx_needs/roles/need_func.py
index 4e921823..16c7b364 100644
--- a/sphinx_needs/roles/need_func.py
+++ b/sphinx_needs/roles/need_func.py
@@ -6,16 +6,35 @@ from __future__ import annotations
 
 from docutils import nodes
 from sphinx.application import Sphinx
+from sphinx.environment import BuildEnvironment
+from sphinx.util.docutils import SphinxRole
 
 from sphinx_needs.data import NeedsInfoType
-from sphinx_needs.functions.functions import check_and_get_content
 from sphinx_needs.logging import get_logger
+from sphinx_needs.utils import add_doc
 
 log = get_logger(__name__)
 
 
+class NeedFuncRole(SphinxRole):
+    """Role for creating ``NeedFunc`` node."""
+
+    def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
+        add_doc(self.env, self.env.docname)
+        node = NeedFunc(
+            self.rawtext, nodes.literal(self.rawtext, self.text), **self.options
+        )
+        self.set_source_info(node)
+        return [node], []
+
+
 class NeedFunc(nodes.Inline, nodes.Element):
-    pass
+    def get_text(self, env: BuildEnvironment, need: NeedsInfoType | None) -> nodes.Text:
+        """Execute function and return result."""
+        from sphinx_needs.functions.functions import check_and_get_content
+
+        result = check_and_get_content(self.astext(), need, env, self)
+        return nodes.Text(str(result))
 
 
 def process_need_func(
@@ -24,12 +43,7 @@ def process_need_func(
     _fromdocname: str,
     found_nodes: list[nodes.Element],
 ) -> None:
-    env = app.env
-    # for node_need_func in doctree.findall(NeedFunc):
-    dummy_need: NeedsInfoType = {"id": "need_func_dummy"}  # type: ignore[typeddict-item]
-    for node_need_func in found_nodes:
-        result = check_and_get_content(
-            node_need_func.attributes["reftarget"], dummy_need, env, node_need_func
-        )
-        new_node_func = nodes.Text(str(result))
+    node_need_func: NeedFunc
+    for node_need_func in found_nodes:  # type: ignore[assignment]
+        new_node_func = node_need_func.get_text(app.env, None)
         node_need_func.replace_self(new_node_func)


Test patch:
diff --git a/tests/doc_test/doc_dynamic_functions/index.rst b/tests/doc_test/doc_dynamic_functions/index.rst
index 71f3705e..dd03f881 100644
--- a/tests/doc_test/doc_dynamic_functions/index.rst
+++ b/tests/doc_test/doc_dynamic_functions/index.rst
@@ -8,6 +8,8 @@ DYNAMIC FUNCTIONS
 
     This is id [[copy("id")]]
 
+    This is also id :need_func:`[[copy("id")]]`
+
 .. spec:: TEST_2
    :id: TEST_2
    :tags: my_tag; [[copy("tags", "SP_TOO_001")]]
@@ -20,6 +22,8 @@ DYNAMIC FUNCTIONS
     :id: TEST_4
     :tags: test_4a;test_4b;[[copy('title')]]
 
+    Test dynamic func in tags: [[copy("tags")]]
+
 .. spec:: TEST_5
     :id: TEST_5
     :tags: [[copy('id')]]
@@ -30,3 +34,7 @@ DYNAMIC FUNCTIONS
         :id: TEST_6
 
         nested id [[copy('id')]]
+
+        nested id also :need_func:`[[copy("id")]]`
+
+This should warn since it has no associated need: :need_func:`[[copy("id")]]`
diff --git a/tests/test_dynamic_functions.py b/tests/test_dynamic_functions.py
index e4652c73..6de4265c 100644
--- a/tests/test_dynamic_functions.py
+++ b/tests/test_dynamic_functions.py
@@ -25,10 +25,13 @@ def test_doc_dynamic_functions(test_app):
     warnings = strip_colors(
         app._warning.getvalue().replace(str(app.srcdir) + os.sep, "srcdir/")
     ).splitlines()
-    assert warnings == []
+    assert warnings == [
+        "srcdir/index.rst:40: 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 (
         sum(1 for _ in re.finditer('<span class="needs_data">test2</span>', html)) == 2
@@ -56,11 +59,14 @@ def test_doc_dynamic_functions(test_app):
         sum(1 for _ in re.finditer('<span class="needs_data">TEST_5</span>', html)) == 2
     )
 
-    assert "Test output of need TEST_3. args:" in html
+    assert "Test output of need_func; 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
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_global_options.py b/tests/test_global_options.py
index 9829f1a7..d5d1e19b 100644
--- a/tests/test_global_options.py
+++ b/tests/test_global_options.py
@@ -21,7 +21,7 @@ def test_doc_global_option(test_app):
 
     assert "test_global" in html
     assert "1.27" in html
-    assert "Test output of need GLOBAL_ID" in html
+    assert "Test output of need_func; need: GLOBAL_ID" in html
 
     assert "STATUS_IMPL" in html
     assert "STATUS_UNKNOWN" in html