diff --git a/packages/sphinx-needs/src/sphinx_needs/config.py b/packages/sphinx-needs/src/sphinx_needs/config.py index 4d55a8a79..74c2b447e 100644 --- a/packages/sphinx-needs/src/sphinx_needs/config.py +++ b/packages/sphinx-needs/src/sphinx_needs/config.py @@ -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, diff --git a/packages/sphinx-needs/src/sphinx_needs/needs.py b/packages/sphinx-needs/src/sphinx_needs/needs.py index 1077ea8db..f16dcecfb 100644 --- a/packages/sphinx-needs/src/sphinx_needs/needs.py +++ b/packages/sphinx-needs/src/sphinx_needs/needs.py @@ -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: diff --git a/packages/sphinx-needs/tests/test_needs_from_toml_functions.py b/packages/sphinx-needs/tests/test_needs_from_toml_functions.py index 4feb99483..f2eaa367a 100644 --- a/packages/sphinx-needs/tests/test_needs_from_toml_functions.py +++ b/packages/sphinx-needs/tests/test_needs_from_toml_functions.py @@ -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"