From 38819d0b7f28fa52bc23556a92855ae22571b431 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 19 Aug 2025 06:21:50 +0200 Subject: [PATCH] attempt to "pre-process" dynamic functions --- sphinx_needs/api/need.py | 152 ++++++++++++++---- sphinx_needs/data.py | 7 - sphinx_needs/functions/functions.py | 91 ++++++++--- sphinx_needs/need_item.py | 36 ++++- .../__snapshots__/test_dynamic_functions.ambr | 50 ++++++ tests/test_dynamic_functions.py | 22 +++ 6 files changed, 293 insertions(+), 65 deletions(-) diff --git a/sphinx_needs/api/need.py b/sphinx_needs/api/need.py index 7b5465fda..fb557f0c4 100644 --- a/sphinx_needs/api/need.py +++ b/sphinx_needs/api/need.py @@ -24,8 +24,14 @@ PredicateContextData, apply_default_predicate, ) +from sphinx_needs.functions.functions import ( + DfString, + DfStringList, + split_string_with_dfs, +) from sphinx_needs.logging import get_logger, log_warning from sphinx_needs.need_item import ( + DynamicFunctionsDict, NeedItem, NeedItemSourceProtocol, NeedItemSourceUnknown, @@ -314,14 +320,47 @@ def generate_need( defaults_extras, defaults_links, ) - extras = _get_default_extras( - extras_no_defaults, needs_config, defaults_ctx, defaults_extras, defaults_links - ) - links = _get_default_links( - links_no_defaults, needs_config, defaults_ctx, defaults_extras, defaults_links - ) + extras = { + key: _get_default_str( + key, value, needs_config, defaults_ctx, defaults_extras, defaults_links + ) + for key, value in extras_no_defaults.items() + } + links = { + key: _get_default_list( + key, value, needs_config, defaults_ctx, defaults_extras, defaults_links + ) + for key, value in links_no_defaults.items() + } + _copy_links(links, needs_config) + dfs: DynamicFunctionsDict = {} + title, title_df = _get_string_df(title) + if title_df: + dfs["title"] = title_df + status, status_df = _get_string_none_df(status) + if status_df: + dfs["status"] = status_df + layout, layout_df = _get_string_none_df(layout) + if layout_df: + dfs["layout"] = layout_df + style, style_df = _get_string_none_df(style) + if style_df: + dfs["style"] = style_df + tags, tags_df = _get_list_df("tags", tags, location) + if tags_df: + dfs["tags"] = tags_df + constraints, constraints_df = _get_list_df("constraints", constraints, location) + if constraints_df: + dfs["constraints"] = constraints_df + extras, extras_df = _get_extras_df(extras) + if extras_df: + dfs["extras"] = extras_df + links, links_df = _get_links_df(links, location) + if links_df: + dfs["links"] = links_df + # Add the need and all needed information needs_data: NeedsInfoType = { "doctype": doctype, @@ -358,7 +397,12 @@ def generate_need( } needs_info = NeedItem( - core=needs_data, extras=extras, links=links, source=source, _validate=False + _validate=False, + core=needs_data, + extras=extras, + links=links, + source=source, + dfs=dfs, ) if jinja_content: @@ -963,32 +1007,6 @@ def _get_default_bool( return _default_value -def _get_default_extras( - value: dict[str, str | None], - config: NeedsSphinxConfig, - context: PredicateContextData, - extras: dict[str, str | None], - links: dict[str, tuple[str, ...]], -) -> dict[str, str]: - return { - key: _get_default_str(key, value[key], config, context, extras, links) - for key in value - } - - -def _get_default_links( - value: dict[str, list[str] | None], - config: NeedsSphinxConfig, - context: PredicateContextData, - extras: dict[str, str | None], - links: dict[str, tuple[str, ...]], -) -> dict[str, list[str]]: - return { - key: _get_default_list(key, value[key], config, context, extras, links) - for key in value - } - - def _get_default( key: str, config: NeedsSphinxConfig, @@ -1006,6 +1024,74 @@ def _get_default( return defaults.get("default", None) +def _get_string_df(value: str) -> tuple[str, None | DfStringList]: + """Split the string into parts that are either dynamic functions or static strings.""" + if (lst := split_string_with_dfs(value)) is None: + return value, None + return "", lst + + +def _get_string_none_df(value: None | str) -> tuple[str | None, None | DfStringList]: + """Split the string into parts that are either dynamic functions or static strings.""" + if value is None: + return None, None + if (lst := split_string_with_dfs(value)) is None: + return value, None + return None, lst + + +def _get_list_df( + key: str, items: list[str], location: tuple[str, int | None] | None +) -> tuple[list[str], list[DfString]]: + """Split the list into parts that are either static strings or contain dynamic functions.""" + static: list[str] = [] + dynamic: list[DfString] = [] + for item in items: + if (lst := split_string_with_dfs(item)) is None: + static.append(item) + else: + if len(lst) > 1: + log_warning( + logger, + f"Dynamic function in list field '{key}' is surrounded by text that will be omitted: {item!r}", + "dynamic_function", + location=location, + ) + dynamic.extend(li for li in lst if isinstance(li, DfString)) + return static, dynamic + + +def _get_extras_df( + extras: dict[str, str], +) -> tuple[dict[str, str], dict[str, DfStringList]]: + """Split the extras into parts that are either static strings or contain dynamic functions.""" + static: dict[str, str] = { + k: "" for k in extras + } # ensure all keys are present in static + dynamic: dict[str, DfStringList] = {} + for key, value in extras.items(): + if (lst := split_string_with_dfs(value)) is None: + static[key] = value + else: + dynamic[key] = lst + return static, dynamic + + +def _get_links_df( + links: dict[str, list[str]], location: tuple[str, int | None] | None +) -> tuple[dict[str, list[str]], dict[str, list[DfString]]]: + """Split the links into parts that are either static strings or contain dynamic functions.""" + static: dict[str, list[str]] = { + k: [] for k in links + } # ensure all keys are present in static + dynamic: dict[str, list[DfString]] = {} + for key, items in links.items(): + static_items, dynamic_items = _get_list_df(key, items, location) + static[key] = static_items + dynamic[key] = dynamic_items + return static, dynamic + + def get_needs_view(app: Sphinx) -> NeedsView: """Return a read-only view of all resolved needs. diff --git a/sphinx_needs/data.py b/sphinx_needs/data.py index 1b2806aa6..0749c9221 100644 --- a/sphinx_needs/data.py +++ b/sphinx_needs/data.py @@ -71,8 +71,6 @@ class CoreFieldParameters(TypedDict): """Whether dynamic functions are allowed for this field (False if not present).""" allow_variants: NotRequired[bool] """Whether variant options are allowed for this field (False if not present).""" - deprecate_df: NotRequired[bool] - """Whether dynamic functions are deprecated for this field (False if not present).""" show_in_layout: NotRequired[bool] """Whether to show the field in the rendered layout of the need by default (False if not present).""" exclude_external: NotRequired[bool] @@ -196,14 +194,12 @@ class CoreFieldParameters(TypedDict): "type": { "description": "Type of the need.", "schema": {"type": "string", "default": ""}, - "deprecate_df": True, }, "type_name": { "description": "Name of the type.", "schema": {"type": "string", "default": ""}, "exclude_external": True, "exclude_import": True, - "deprecate_df": True, }, "type_prefix": { "description": "Prefix of the type.", @@ -211,7 +207,6 @@ class CoreFieldParameters(TypedDict): "exclude_json": True, "exclude_external": True, "exclude_import": True, - "deprecate_df": True, }, "type_color": { "description": "Hexadecimal color code of the type.", @@ -219,7 +214,6 @@ class CoreFieldParameters(TypedDict): "exclude_json": True, "exclude_external": True, "exclude_import": True, - "deprecate_df": True, }, "type_style": { "description": "Style of the type.", @@ -227,7 +221,6 @@ class CoreFieldParameters(TypedDict): "exclude_json": True, "exclude_external": True, "exclude_import": True, - "deprecate_df": True, }, "is_modified": { "description": "Whether the need was modified by needextend.", diff --git a/sphinx_needs/functions/functions.py b/sphinx_needs/functions/functions.py index 16efe1689..67a2430eb 100644 --- a/sphinx_needs/functions/functions.py +++ b/sphinx_needs/functions/functions.py @@ -10,7 +10,8 @@ import ast import re -from typing import Any, Protocol +from dataclasses import dataclass +from typing import Any, Protocol, TypeAlias from docutils import nodes from sphinx.application import Sphinx @@ -134,6 +135,76 @@ def execute_func( FUNC_RE = re.compile(r"\[\[(.*?)\]\]") # RegEx to detect function strings +class _PeekableChars: + """A simple iterator that allows peeking at the next character.""" + + def __init__(self, chars: str): + self._chars = chars + self._index = -1 + + def next(self) -> str | None: + """Return the next character and advance the iterator.""" + self._index += 1 + try: + return self._chars[self._index] + except IndexError: + return None + + def peek(self) -> str | None: + """Return the next character without consuming it.""" + try: + return self._chars[self._index + 1] + except IndexError: + return None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DfString: + expr: str + + +DfStringList: TypeAlias = list[str | DfString] + + +def split_string_with_dfs(value: str) -> None | DfStringList: + """A dynamic function in strings is enclosed by `[[` and `]]`. + This function splits the string into parts that are either dynamic functions or static strings. + + Returns None if there are no dynamic functions, i.e. the string is a single static string. + """ + parts: DfStringList = [] + curr_chars: str = "" + in_df: bool = False + chars = _PeekableChars(value) + while (c := chars.next()) is not None: + if not in_df and c == "[" and chars.peek() == "[": + # start of dynamic function + chars.next() # consume second '[' + in_df = True + if curr_chars: + parts.append(curr_chars) + curr_chars = "" + elif in_df and c == "]" and chars.peek() == "]": + # end of dynamic function + in_df = False + chars.next() # consume second ']' + if curr_chars: + parts.append(DfString(expr=curr_chars)) + curr_chars = "" + else: + # normal character + curr_chars += c + + if not parts: + return None + + if curr_chars: + # TODO warn if unclosed df? + parts.append(curr_chars) + + return parts + + def find_and_replace_node_content( node: nodes.Node, env: BuildEnvironment, need: NeedItem ) -> nodes.Node: @@ -235,17 +306,10 @@ def resolve_dynamic_values(needs: NeedsMutable, app: Sphinx) -> None: config = NeedsSphinxConfig(app.config) allowed_fields: set[str] = { - *( - k - for k, v in NeedsCoreFields.items() - if v.get("allow_df", False) or v.get("deprecate_df", False) - ), + *(k for k, v in NeedsCoreFields.items() if v.get("allow_df", False)), *config.extra_options, *(link["option"] for link in config.extra_links), } - deprecated_fields: set[str] = { - *(k for k, v in NeedsCoreFields.items() if v.get("deprecate_df", False)), - } for need in needs.values(): for need_option in need: @@ -271,15 +335,6 @@ def resolve_dynamic_values(needs: NeedsMutable, app: Sphinx) -> None: if func_call is None: continue - if need_option in deprecated_fields: - log_warning( - logger, - f"Usage of dynamic functions is deprecated in field {need_option!r}, found in need {need['id']!r}", - "deprecated", - location=(need["docname"], need["lineno"]) - if need["docname"] - else None, - ) # Replace original function string with return value of function call if func_return is None: diff --git a/sphinx_needs/need_item.py b/sphinx_needs/need_item.py index d93532124..610cd4f4a 100644 --- a/sphinx_needs/need_item.py +++ b/sphinx_needs/need_item.py @@ -12,15 +12,25 @@ from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, field from itertools import chain -from typing import Any, Literal, Protocol, overload, runtime_checkable - -from sphinx_needs.data import ( - NeedsInfoComputedType, - NeedsInfoType, - NeedsPartType, - NeedsSourceInfoType, +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Protocol, + TypedDict, + overload, + runtime_checkable, ) +if TYPE_CHECKING: + from sphinx_needs.data import ( + NeedsInfoComputedType, + NeedsInfoType, + NeedsPartType, + NeedsSourceInfoType, + ) + from sphinx_needs.functions.functions import DfString, DfStringList + @runtime_checkable class NeedItemSourceProtocol(Protocol): @@ -138,6 +148,17 @@ def __post_init__(self) -> None: self.dict_repr.update(d) +class DynamicFunctionsDict(TypedDict, total=False): + title: DfStringList + status: DfStringList + layout: DfStringList + style: DfStringList + tags: list[DfString] + constraints: list[DfString] + extras: dict[str, DfStringList] + links: dict[str, list[DfString]] + + class NeedItem: """A class representing a single need item.""" @@ -167,6 +188,7 @@ def __init__( extras: dict[str, str], links: dict[str, list[str]], backlinks: dict[str, list[str]] | None = None, + dfs: DynamicFunctionsDict | None = None, _validate: bool = True, ) -> None: """Initialize the NeedItem instance. diff --git a/tests/__snapshots__/test_dynamic_functions.ambr b/tests/__snapshots__/test_dynamic_functions.ambr index 1ef9a0cb6..53aa7e17e 100644 --- a/tests/__snapshots__/test_dynamic_functions.ambr +++ b/tests/__snapshots__/test_dynamic_functions.ambr @@ -592,3 +592,53 @@ }), }) # --- +# name: test_split_string_with_dfs[ [[func()]] ] + list([ + ' ', + DfString(expr='func()'), + ' ', + ]) +# --- +# name: test_split_string_with_dfs[[[bad]]] + list([ + DfString(expr='bad'), + ]) +# --- +# name: test_split_string_with_dfs[[[func()] + None +# --- +# name: test_split_string_with_dfs[[[func()]] + None +# --- +# name: test_split_string_with_dfs[[[func()]]] + list([ + DfString(expr='func()'), + ]) +# --- +# name: test_split_string_with_dfs[a,b] + None +# --- +# name: test_split_string_with_dfs[a[[func()]]] + list([ + 'a', + DfString(expr='func()'), + ]) +# --- +# name: test_split_string_with_dfs[a[[func()]]b] + list([ + 'a', + DfString(expr='func()'), + 'b', + ]) +# --- +# name: test_split_string_with_dfs[a[[func1()]]b[[func2()]]] + list([ + 'a', + DfString(expr='func1()'), + 'b', + DfString(expr='func2()'), + ]) +# --- +# name: test_split_string_with_dfs[a] + None +# --- diff --git a/tests/test_dynamic_functions.py b/tests/test_dynamic_functions.py index 7d5e7981a..7c95dc179 100644 --- a/tests/test_dynamic_functions.py +++ b/tests/test_dynamic_functions.py @@ -7,6 +7,28 @@ from sphinx.util.console import strip_colors from syrupy.filters import props +from sphinx_needs.functions.functions import split_string_with_dfs + + +@pytest.mark.parametrize( + "input", + [ + "a", + "a,b", + "[[func()]]", + " [[func()]] ", + "a[[func()]]", + "a[[func()]]b", + "a[[func1()]]b[[func2()]]", + "[[func()]", + "[[func()", + "[[bad]]", + ], +) +def test_split_string_with_dfs(input: str, snapshot) -> None: + result = split_string_with_dfs(input) + assert result == snapshot + @pytest.mark.parametrize( "test_app",