Skip to content

attempt to "pre-process" dynamic functions #1497

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 119 additions & 33 deletions sphinx_needs/api/need.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand Down
7 changes: 0 additions & 7 deletions sphinx_needs/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -196,38 +194,33 @@ 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.",
"schema": {"type": "string", "default": ""},
"exclude_json": True,
"exclude_external": True,
"exclude_import": True,
"deprecate_df": True,
},
"type_color": {
"description": "Hexadecimal color code of the type.",
"schema": {"type": "string", "default": ""},
"exclude_json": True,
"exclude_external": True,
"exclude_import": True,
"deprecate_df": True,
},
"type_style": {
"description": "Style of the type.",
"schema": {"type": "string", "default": ""},
"exclude_json": True,
"exclude_external": True,
"exclude_import": True,
"deprecate_df": True,
},
"is_modified": {
"description": "Whether the need was modified by needextend.",
Expand Down
91 changes: 73 additions & 18 deletions sphinx_needs/functions/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading