Skip to content
Closed
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
10 changes: 9 additions & 1 deletion packages/sphinx-needs/src/sphinx_needs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,15 @@ def functions(self) -> Mapping[str, NeedFunctionsType]:

def add_function(self, function: DynamicFunction, name: str | None = None) -> None:
"""Adds a dynamic function to the configuration."""
func_name = function.__name__ if name is None else name
func_name = getattr(function, "__name__", None) if name is None else name
if func_name is None:
log_warning(
LOGGER,
f"Dynamic function {function!r} has no __name__ and no name was provided.",
"config",
None,
)
return
if func_name in self._functions:
log_warning(
LOGGER,
Expand Down
30 changes: 20 additions & 10 deletions packages/sphinx-needs/src/sphinx_needs/needs.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,16 +993,26 @@ def merge_default_configs(_app: Sphinx, config: Config) -> None:
_NEEDS_CONFIG.add_function(need_common_func)

# Register functions configured by user
for needs_func in needs_config._functions:
if not callable(needs_func):
log_warning(
LOGGER,
f"needs_functions entry {needs_func!r} is not callable and is ignored",
"config",
None,
)
continue
_NEEDS_CONFIG.add_function(needs_func)
if isinstance(needs_config._functions, (list, tuple)):
for needs_func in needs_config._functions:
if not callable(needs_func):
log_warning(
LOGGER,
f"needs_functions entry {needs_func!r} is not callable and is ignored",
"config",
None,
)
continue
if not getattr(needs_func, "__name__", None):
log_warning(
LOGGER,
f"needs_functions entry {needs_func!r} has no __name__ and is ignored "
"(use a callable with a __name__, or add_dynamic_function(func, name=...) for one without)",
"config",
None,
)
continue
_NEEDS_CONFIG.add_function(needs_func)

# The default link name. Must exist in all configurations. Therefore we set it here for the user.
if "links" not in needs_config._links:
Expand Down
67 changes: 67 additions & 0 deletions packages/sphinx-needs/tests/test_needs_from_toml_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,70 @@ def test_a_function_from_conf_py_is_still_registered_beside_a_toml_file(build):
assert_no_warnings(app)
assert app.config.needs_id_required is True
assert _need(app, "R_ONE")["status"] == "forty-two"


def test_needs_functions_entry_without_name_warns_and_is_ignored(build):
"""A callable without a __name__ (e.g. functools.partial) in needs_functions warns
and is ignored rather than raising AttributeError."""
conf = """\
extensions = ["sphinx_needs"]
suppress_warnings = ["config.cache"]
import functools

needs_functions = [functools.partial(min, 1)]
"""
app = build({"conf.py": conf})
warnings = build_warnings(app)
assert len(warnings) == 1
assert "has no __name__ and is ignored" in warnings[0]
assert "[needs.config]" in warnings[0]
assert app.statuscode == 0


def test_needs_functions_none_value_does_not_crash(build):
"""Setting needs_functions = None does not crash with TypeError."""
app = build({"conf.py": 'extensions = ["sphinx_needs"]\nneeds_functions = None\n'})
assert app.statuscode == 0


def test_needs_functions_bare_callable_value_does_not_crash(build):
"""Setting needs_functions = func (a bare function instead of a list) does not crash."""
conf = """\
extensions = ["sphinx_needs"]
suppress_warnings = ["config.cache"]

def dummy():
pass

needs_functions = dummy
"""
app = build({"conf.py": conf})
assert app.statuscode == 0


def test_add_dynamic_function_allows_callables_without_name(build):
"""add_dynamic_function allows registering a callable without __name__ when a name is given."""
conf = """\
extensions = ["sphinx_needs"]
needs_build_json = True
suppress_warnings = ["config.cache"]
import functools
from sphinx_needs.api import add_dynamic_function

def raw_answer(app, need, needs, extra):
return f"answer-{extra}"

def setup(app):
add_dynamic_function(app, functools.partial(raw_answer, extra="partial"), name="partial_func")
"""
rst = """\
Title
=====

.. req:: One
:id: R_ONE
:status: [[partial_func()]]
"""
app = build({"conf.py": conf, "index.rst": rst})
assert_no_warnings(app)
assert _need(app, "R_ONE")["status"] == "answer-partial"
Loading