From 3adc639def37260144e111c30964fb4541fa7640 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:54:05 +0800 Subject: [PATCH] fix(decorators): guard _get_fn_argnames against empty positional args (closes #2422) _get_fn_argnames() accessed arg_spec_args[0] without checking if the list was empty. Functions with only keyword-only args (def f(*, x)), variadic args (def f(*args)), or keyword-variadic args (def f(**kwargs)) have an empty args list from inspect.getfullargspec, causing IndexError. Fix: add an early return when arg_spec_args is empty, before the [0] access. This is safe because there are no self/cls args to exclude. Impact: any pandera decorator (@check_input, @check_output, @check_io, @check_types) applied to a function with only keyword-only parameters would crash during argument introspection. Regression tests cover: - keyword-only args (exact repro from #2422) - variadic args only (*args) - kwargs only (**kwargs) - no args at all - Normal cases still work (regular function, method excludes self, mixed positional + keyword-only) Signed-off-by: fei <204683769+feiiiiii5@users.noreply.github.com> --- pandera/decorators.py | 5 ++ tests/pandas/test_get_fn_argnames_empty.py | 85 ++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/pandas/test_get_fn_argnames_empty.py diff --git a/pandera/decorators.py b/pandera/decorators.py index e6af8c1fc..bdf3a7511 100644 --- a/pandera/decorators.py +++ b/pandera/decorators.py @@ -70,6 +70,11 @@ def _get_fn_argnames(fn: Callable) -> list[str]: arg_spec_args = inspect.getfullargspec(fn).args + # Functions with only keyword-only args, *args, or **kwargs have + # no positional args → nothing to introspect for self/cls. + if not arg_spec_args: + return arg_spec_args + first_arg_is_self = arg_spec_args[0] == "self" first_arg_is_cls = arg_spec_args[0] == "cls" is_py_newer_than_39 = sys.version_info[:2] >= (3, 9) diff --git a/tests/pandas/test_get_fn_argnames_empty.py b/tests/pandas/test_get_fn_argnames_empty.py new file mode 100644 index 000000000..3265d8fb7 --- /dev/null +++ b/tests/pandas/test_get_fn_argnames_empty.py @@ -0,0 +1,85 @@ +""" +Regression tests for _get_fn_argnames IndexError on keyword-only functions +(GitHub issue #2422). + +_get_fn_argnames() in pandera/decorators.py accessed arg_spec_args[0] +without checking if the list was empty. Functions with only keyword-only +args (``def f(*, x)``), variadic args (``def f(*args)``), or keyword- +variadic args (``def f(**kwargs)``) have an empty ``args`` list from +``inspect.getfullargspec``, causing IndexError. + +The fix adds an early return when arg_spec_args is empty. +""" + +import pytest +from pandera.decorators import _get_fn_argnames + + +class TestGetFnArgnamesEmptyArgs: + """Functions with no positional args must not crash.""" + + def test_keyword_only_args(self): + """def f(*, df, output_col) → returns [] without IndexError.""" + + def process(*, df, output_col): + pass + + result = _get_fn_argnames(process) + assert result == [] + + def test_variadic_args_only(self): + """def f(*args) → returns [] without IndexError.""" + + def process(*args): + pass + + result = _get_fn_argnames(process) + assert result == [] + + def test_kwargs_only(self): + """def f(**kwargs) → returns [] without IndexError.""" + + def process(**kwargs): + pass + + result = _get_fn_argnames(process) + assert result == [] + + def test_no_args_at_all(self): + """def f() → returns [] without IndexError.""" + + def process(): + pass + + result = _get_fn_argnames(process) + assert result == [] + + +class TestGetFnArgnamesNormalCases: + """Normal functions must still work correctly.""" + + def test_regular_function(self): + def process(df, output_col): + pass + + result = _get_fn_argnames(process) + assert result == ["df", "output_col"] + + def test_method_excludes_self(self): + class MyClass: + def process(self, df, output_col): + pass + + obj = MyClass() + result = _get_fn_argnames(obj.process) + assert result == ["df", "output_col"] + assert "self" not in result + + def test_mixed_positional_and_keyword_only(self): + """def f(a, b, *, c) → returns ['a', 'b'] (keyword-only excluded).""" + + def process(a, b, *, c): + pass + + result = _get_fn_argnames(process) + assert result == ["a", "b"]