Skip to content

Commit dcbdd6f

Browse files
FrancescAltedclaude
andcommitted
Point the "unexpected keyword" error at the kernel(**df) fix
Unpacking a frame that carries more columns than the kernel has parameters is the expected way to get here, and the fix -- subset the frame -- was only findable in the guide: TypeError: traced() got an unexpected keyword argument 'note' If you are calling traced(**df), subset the frame to the kernel's parameters: traced(**df[['a', 'b']]) Extra keywords stay an error rather than being filtered to the kernel's parameters: the wrapper cannot tell "wide frame, take what you need" from "this keyword was meant to do something", and silently dropping the latter turns a typo or a stale argument name into wrong numbers. Both jit routes gain the hint, and the DSL route also gains the function name that sig.bind's message never carried. Missing operands are a different mistake and keep their own message. Also replace lazyudf's arity mismatch, which surfaced as a bare "zip() argument 2 is longer than argument 1", with one naming the kernel, its parameters and both counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 815b4db commit dcbdd6f

4 files changed

Lines changed: 100 additions & 11 deletions

File tree

doc/guides/pandas_engine.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ orbits["E"] = kepler(**orbits)
110110
```
111111

112112
No `apply`, no `engine=`. The parameter names match the column names, so `**`
113-
does the wiring; each column arrives as a pandas Series, which the kernel
114-
accepts like any array (zero-copy for ordinary numeric dtypes). `**df` passes
113+
does the wiring — by name, so the column order in the frame is irrelevant.
114+
Each column arrives as a pandas Series, which the kernel accepts like any
115+
array (zero-copy for ordinary numeric dtypes). `**df` passes
115116
*every* column, so subset first if the frame has more:
116117
`kepler(**orbits[["mean_anomaly", "eccentricity"]])`.
117118

src/blosc2/lazyexpr.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4665,7 +4665,16 @@ def __init__(
46654665
# DSL kernels are using input names that are extracted from params as a list,
46664666
# and we need to use them for matching variables in miniexpr
46674667
# (instead of the 'o{%d}' notation).
4668-
self.inputs_dict = dict(zip(self.func.input_names, self.inputs, strict=True))
4668+
names = self.func.input_names
4669+
if len(names) != len(self.inputs):
4670+
# Otherwise this surfaces as a bare "zip() argument 2 is longer
4671+
# than argument 1", which names neither the kernel nor the counts.
4672+
udf_name = getattr(self.func.func, "__name__", self.func.__name__)
4673+
raise ValueError(
4674+
f"DSL kernel {udf_name!r} takes {len(names)} operand(s) "
4675+
f"({', '.join(names)}), but {len(self.inputs)} were passed."
4676+
)
4677+
self.inputs_dict = dict(zip(names, self.inputs, strict=True))
46694678
else:
46704679
self.inputs_dict = {f"o{i}": obj for i, obj in enumerate(self.inputs)}
46714680

src/blosc2/proxy.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,34 @@ def _has_control_flow(source: str | None) -> bool:
876876
return any(isinstance(node, ast.If | ast.For | ast.While) for node in ast.walk(tree))
877877

878878

879+
def _wide_frame_hint(err: BaseException, func_name: str, params) -> str | None:
880+
"""Guidance to append when a call gets a keyword the function doesn't take.
881+
882+
The usual cause is the `kernel(**df)` idiom (see doc/guides/pandas_engine.md)
883+
against a frame carrying more columns than the kernel has parameters. Extra
884+
keywords are rejected rather than dropped, so that a keyword meant to do
885+
something -- a typo, a stale argument name -- never goes silently unused.
886+
"""
887+
if not isinstance(err, TypeError) or "unexpected keyword argument" not in str(err):
888+
return None
889+
params = list(params)
890+
if not params:
891+
return None
892+
cols = ", ".join(repr(p) for p in params)
893+
return (
894+
f"If you are calling {func_name}(**df), subset the frame to the "
895+
f"kernel's parameters: {func_name}(**df[[{cols}]])"
896+
)
897+
898+
899+
def _signature_params(func) -> list:
900+
"""Parameter names of *func*, or an empty list if it cannot be introspected."""
901+
try:
902+
return list(inspect.signature(func).parameters)
903+
except (TypeError, ValueError):
904+
return []
905+
906+
879907
def _jit_dsl_wrapper(kernel: DSLKernel, out, decorator_kwargs: dict):
880908
"""Build the call wrapper for the DSL (control-flow) dispatch route of `jit`.
881909
@@ -889,7 +917,13 @@ def dsl_wrapper(*args, **func_kwargs):
889917
sig = kernel._sig
890918
if sig is None:
891919
raise TypeError(f"@blosc2.jit: cannot introspect the signature of {kernel.__name__!r}")
892-
bound = sig.bind(*args, **func_kwargs)
920+
try:
921+
bound = sig.bind(*args, **func_kwargs)
922+
except TypeError as e:
923+
# sig.bind's message names no function; prefix it, and point at the
924+
# subsetting fix when a wide DataFrame was unpacked into the call.
925+
hint = _wide_frame_hint(e, kernel.__name__, kernel.input_names or sig.parameters)
926+
raise TypeError(f"{kernel.__name__}() {e}" + (f"\n{hint}" if hint else "")) from None
893927
bound.apply_defaults()
894928
values = tuple(bound.arguments[name] for name in kernel.input_names)
895929
# Accept array-protocol operands (pandas Series, polars Series, ...) the
@@ -1137,8 +1171,18 @@ def wrapper(*args, **func_kwargs):
11371171
try:
11381172
retval = func(*new_args, **func_kwargs)
11391173
except Exception as e:
1140-
if _trace_hint is not None:
1141-
raise type(e)(f"{e}\n{_trace_hint}") from e
1174+
hints = [
1175+
hint
1176+
for hint in (
1177+
_wide_frame_hint(
1178+
e, getattr(func, "__name__", "the function"), _signature_params(func)
1179+
),
1180+
_trace_hint,
1181+
)
1182+
if hint is not None
1183+
]
1184+
if hints:
1185+
raise type(e)("\n".join([str(e), *hints])) from e
11421186
raise
11431187

11441188
# Treat return value

tests/test_pandas_udf_engine.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,10 +325,35 @@ def not_dsl(col):
325325
def test_columns_by_keyword_unpacking(self):
326326
# doc/guides/pandas_engine.md's row-wise pattern: a DataFrame is a
327327
# mapping of column name to Series, so `kernel(**df)` passes each
328-
# column as a keyword argument. Both jit routes must accept that.
328+
# column as a keyword argument. Both jit routes must accept that, and
329+
# bind by name: the columns below are in neither the parameter order
330+
# nor alphabetical order, and the operations are asymmetric, so a
331+
# positional binding would give a different (wrong) answer.
329332
@blosc2.jit
330333
def traced(a, b):
331-
return np.sqrt(a * a + b * b)
334+
return b - a * 2.0
335+
336+
@blosc2.jit
337+
def dsl(a, b):
338+
if a > b:
339+
out = a - b
340+
else:
341+
out = b - a * 2.0
342+
return out
343+
344+
df = pd.DataFrame({"b": [4.0, -5.0, 6.0], "a": [-2.0, 1.0, 3.0]})
345+
assert list(df.columns) == ["b", "a"]
346+
347+
np.testing.assert_allclose(np.asarray(traced(**df)), np.asarray(traced(df["a"], df["b"])))
348+
np.testing.assert_allclose(np.asarray(traced(**df)), df["b"] - df["a"] * 2.0)
349+
np.testing.assert_allclose(np.asarray(dsl(**df)), np.asarray(dsl(df["a"], df["b"])))
350+
351+
def test_wide_frame_kwargs_error_names_the_fix(self):
352+
# Extra columns are rejected, not dropped: a keyword that goes nowhere
353+
# would otherwise fail silently. The message must name the subsetting fix.
354+
@blosc2.jit
355+
def traced(a, b):
356+
return a + b
332357

333358
@blosc2.jit
334359
def dsl(a, b):
@@ -338,7 +363,17 @@ def dsl(a, b):
338363
out = b - a
339364
return out
340365

341-
df = pd.DataFrame({"a": [-2.0, 1.0, 3.0], "b": [4.0, -5.0, 6.0]})
366+
df = pd.DataFrame({"a": [1.0, 2.0], "b": [4.0, 5.0], "note": [7.0, 8.0]})
367+
368+
# (`func.__name__` is the jit wrapper's; the message uses the kernel's)
369+
for name, func in (("traced", traced), ("dsl", dsl)):
370+
with pytest.raises(TypeError) as excinfo:
371+
func(**df)
372+
message = str(excinfo.value)
373+
assert name in message
374+
assert "'note'" in message
375+
assert "**df[['a', 'b']]" in message
342376

343-
np.testing.assert_allclose(np.asarray(traced(**df)), np.hypot(df["a"], df["b"]))
344-
np.testing.assert_allclose(np.asarray(dsl(**df)), np.abs(df["a"] - df["b"]))
377+
# A missing operand is a different mistake and keeps its own message
378+
with pytest.raises(TypeError, match="missing a required argument"):
379+
dsl(a=df["a"])

0 commit comments

Comments
 (0)