Skip to content

funcutils: forward defaulted args as keyword args in wraps invocation#412

Open
gaoflow wants to merge 1 commit into
mahmoud:masterfrom
gaoflow:fix-wraps-kwargs-defaulted-args
Open

funcutils: forward defaulted args as keyword args in wraps invocation#412
gaoflow wants to merge 1 commit into
mahmoud:masterfrom
gaoflow:fix-wraps-kwargs-defaulted-args

Conversation

@gaoflow

@gaoflow gaoflow commented Jun 24, 2026

Copy link
Copy Markdown

Fixes #343.

The bug

FunctionBuilder.get_invocation_str() passes every regular argument
positionally in the generated body:

# generated for:  def pow(x, y, msg=''):
return _call(x, y, msg)

When a caller supplies a defaulted parameter by keyword (pow(3, 2, msg='abc')),
the generated function correctly binds msg='abc', but then forwards it
positionally as _call(3, 2, 'abc'). Any wrapper that inspects
*args / **kwargs separately—the standard pattern—loses the keyword
information:

def flip(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        return f(*reversed(args), **kwargs)
    return wrapper

def power(x, y, msg=''):
    return (x, y, msg)

flip(power)(3, 2, msg='abc')
# before: ('abc', 2, 3)  – msg landed in args, was reversed
# after:  (2, 3, 'abc')  – correct

The fix

Args that carry a default are now forwarded with the name=name form:

return _call(x, y, msg=msg)

This means the inner wrapper receives them in **kwargs, preserving
exactly how the caller passed them.

As a welcome side-effect, this also lifts the limitation documented in
test_wraps_inner_kwarg_only (issue #261): wrapping a keyword-only-b
function with a signature that exposes b as positional-or-keyword now
works, because the generated invocation passes b=b.

Test changes

  • Added test_wraps_preserves_kwargs_for_defaulted_args in
    test_funcutils.py directly covering the issue wraps loses keywords #343 scenario.
  • test_wraps_inner_kwarg_only: updated docstring and assertion to
    reflect that wraps(g)(f)(3) now succeeds (was asserting TypeError
    which documented the old limitation).
  • test_wraps_expected (expect_pair / expect_dict): these wrappers
    extracted defaulted injected args from args[-1], relying on the old
    positional-passthrough. Updated to use kwargs.pop(...), which is the
    correct pattern under the new semantics.
  • Parametrized test_get_invocation_sig_str: updated expected invocation
    string for the single-defaulted-arg case ("a""a=a").

All 438 tests pass.

…tion

`FunctionBuilder.get_invocation_str()` was passing every regular
argument positionally (`return _call(x, y, msg)`).  When a caller
supplied a defaulted parameter by keyword (`f(3, 2, msg='abc')`), the
generated wrapper bound it correctly (`msg='abc'`) but then forwarded
it positionally to `_call` (`_call(3, 2, 'abc')`).  Any wrapper that
inspects `*args` and `**kwargs` separately therefore lost the keyword
information, producing the wrong result (mahmoud#343).

Fix: arguments that carry a default in the original function are now
forwarded with the `name=name` form (`return _call(x, y, msg=msg)`),
so the wrapper receives them in `**kwargs` exactly as the caller passed
them.

As a side-effect, this also resolves the long-standing limitation noted
in `test_wraps_inner_kwarg_only` (issue mahmoud#261): wrapping a
keyword-only-`b` function with a signature that treats `b` as
positional-or-keyword now works, because the generated invocation
passes `b=b` which satisfies the keyword-only constraint.

Tests that relied on extracting defaulted injected args from `*args`
are updated to use `**kwargs` instead, which is the correct pattern
under the new semantics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wraps loses keywords

1 participant