From 56c2f8cd693dcb2ee2c05bc3175a272cb3933c5e Mon Sep 17 00:00:00 2001 From: Peng Ding Date: Mon, 20 Apr 2026 13:03:24 +0800 Subject: [PATCH] fix(#126): emit warnings for silent parameter introspection failures - Warn when *args or **kwargs are skipped during JSON Schema generation - Warn when parameter model generation fails in Tool.from_function() instead of silently producing an empty schema --- src/toolregistry/parameter_models.py | 24 +++- src/toolregistry/tool.py | 9 +- tests/test_param_warnings.py | 165 +++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 tests/test_param_warnings.py diff --git a/src/toolregistry/parameter_models.py b/src/toolregistry/parameter_models.py index 5182a47..f546ecb 100644 --- a/src/toolregistry/parameter_models.py +++ b/src/toolregistry/parameter_models.py @@ -1,4 +1,5 @@ import inspect +import warnings from typing import Any, get_type_hints from collections.abc import Callable @@ -122,10 +123,25 @@ def _generate_parameters_model(func: Callable) -> type[ArgModelBase] | None: if param.name == "self": continue # Skip *args and **kwargs — they are not individual named parameters - if param.kind in ( - inspect.Parameter.VAR_POSITIONAL, - inspect.Parameter.VAR_KEYWORD, - ): + if param.kind == inspect.Parameter.VAR_POSITIONAL: + warnings.warn( + f"Parameter '*{param.name}' (*args) in " + f"'{getattr(func, '__name__', '')}' is not " + "representable in JSON Schema and will be excluded " + "from the tool schema.", + UserWarning, + stacklevel=2, + ) + continue + if param.kind == inspect.Parameter.VAR_KEYWORD: + warnings.warn( + f"Parameter '**{param.name}' (**kwargs) in " + f"'{getattr(func, '__name__', '')}' is not " + "representable in JSON Schema and will be excluded " + "from the tool schema.", + UserWarning, + stacklevel=2, + ) continue annotation = _get_typed_annotation(param.annotation, globalns) diff --git a/src/toolregistry/tool.py b/src/toolregistry/tool.py index 9704e03..d22b9f9 100644 --- a/src/toolregistry/tool.py +++ b/src/toolregistry/tool.py @@ -1,4 +1,5 @@ import inspect +import warnings from enum import Enum from typing import Any, Literal from collections.abc import Callable @@ -296,7 +297,13 @@ def from_function( parameters_model = None try: parameters_model = _generate_parameters_model(func) - except Exception: + except Exception as e: + warnings.warn( + f"Failed to generate parameter model for '{func_name}': {e}. " + "The tool will be registered without parameter validation.", + UserWarning, + stacklevel=2, + ) parameters_model = None parameters_schema = ( parameters_model.model_json_schema() if parameters_model else {} diff --git a/tests/test_param_warnings.py b/tests/test_param_warnings.py new file mode 100644 index 0000000..13b8bde --- /dev/null +++ b/tests/test_param_warnings.py @@ -0,0 +1,165 @@ +"""Tests for parameter introspection warnings. + +Verifies that warnings are emitted when: +- *args (VAR_POSITIONAL) parameters are skipped during schema generation +- **kwargs (VAR_KEYWORD) parameters are skipped during schema generation +- Parameter model generation fails entirely +""" + +import warnings +from unittest.mock import patch + +import pytest + +from toolregistry.parameter_models import _generate_parameters_model +from toolregistry.tool import Tool + + +class TestVarPositionalWarning: + """Test that *args parameters emit a warning.""" + + def test_args_emits_warning(self): + """Registering a function with *args should warn about exclusion.""" + + def func_with_args(x: int, *args: str) -> str: + return str(x) + + with pytest.warns( + UserWarning, + match=r"Parameter '\*args' \(\*args\) in 'func_with_args'.*excluded", + ): + _generate_parameters_model(func_with_args) + + def test_args_via_tool_from_function(self): + """Tool.from_function with *args should warn about exclusion.""" + + def func_with_args(x: int, *args: str) -> str: + return str(x) + + with pytest.warns( + UserWarning, + match=r"Parameter '\*args' \(\*args\) in 'func_with_args'.*excluded", + ): + tool = Tool.from_function(func_with_args) + + # The tool should still be created successfully, with only 'x' in schema + assert tool is not None + assert "x" in tool.parameters.get("properties", {}) + + def test_custom_args_name(self): + """Custom *args name should appear in the warning message.""" + + def func_with_custom_args(x: int, *my_args: str) -> str: + return str(x) + + with pytest.warns( + UserWarning, + match=r"Parameter '\*my_args' \(\*args\) in 'func_with_custom_args'", + ): + _generate_parameters_model(func_with_custom_args) + + +class TestVarKeywordWarning: + """Test that **kwargs parameters emit a warning.""" + + def test_kwargs_emits_warning(self): + """Registering a function with **kwargs should warn about exclusion.""" + + def func_with_kwargs(x: int, **kwargs: str) -> str: + return str(x) + + with pytest.warns( + UserWarning, + match=r"Parameter '\*\*kwargs' \(\*\*kwargs\) in 'func_with_kwargs'.*excluded", + ): + _generate_parameters_model(func_with_kwargs) + + def test_kwargs_via_tool_from_function(self): + """Tool.from_function with **kwargs should warn about exclusion.""" + + def func_with_kwargs(x: int, **kwargs: str) -> str: + return str(x) + + with pytest.warns( + UserWarning, + match=r"Parameter '\*\*kwargs' \(\*\*kwargs\) in 'func_with_kwargs'.*excluded", + ): + tool = Tool.from_function(func_with_kwargs) + + # The tool should still be created successfully, with only 'x' in schema + assert tool is not None + assert "x" in tool.parameters.get("properties", {}) + + def test_custom_kwargs_name(self): + """Custom **kwargs name should appear in the warning message.""" + + def func_with_custom_kwargs(x: int, **options: str) -> str: + return str(x) + + with pytest.warns( + UserWarning, + match=r"Parameter '\*\*options' \(\*\*kwargs\) in 'func_with_custom_kwargs'", + ): + _generate_parameters_model(func_with_custom_kwargs) + + +class TestBothArgsAndKwargs: + """Test that functions with both *args and **kwargs emit two warnings.""" + + def test_args_and_kwargs_both_warn(self): + """Both *args and **kwargs should each produce a warning.""" + + def func_with_both(x: int, *args: str, **kwargs: str) -> str: + return str(x) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _generate_parameters_model(func_with_both) + + user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] + assert len(user_warnings) == 2 + + messages = [str(w.message) for w in user_warnings] + assert any("*args" in m and "'*args'" in m for m in messages) + assert any("**kwargs" in m and "'**kwargs'" in m for m in messages) + + +class TestParameterModelGenerationFailureWarning: + """Test that failure in _generate_parameters_model emits a warning in from_function.""" + + def test_generation_failure_emits_warning(self): + """When _generate_parameters_model raises, from_function should warn.""" + + def normal_func(x: int) -> str: + return str(x) + + with patch( + "toolregistry.tool._generate_parameters_model", + side_effect=RuntimeError("mock introspection failure"), + ): + with pytest.warns( + UserWarning, + match=r"Failed to generate parameter model for 'normal_func'.*mock introspection failure", + ): + tool = Tool.from_function(normal_func) + + # Tool should still be created, but without parameter validation + assert tool is not None + assert tool.parameters_model is None + assert tool.parameters == {} + + def test_generation_failure_warning_includes_func_name(self): + """Warning message should include the function name.""" + + def my_special_func(x: int) -> str: + return str(x) + + with patch( + "toolregistry.tool._generate_parameters_model", + side_effect=ValueError("bad annotation"), + ): + with pytest.warns( + UserWarning, + match=r"'my_special_func'.*bad annotation.*without parameter validation", + ): + Tool.from_function(my_special_func)