From 90047da8dac4315e5917aa77a9a9338bb8a5d359 Mon Sep 17 00:00:00 2001 From: Miro <200482516+Mirochill@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:14:44 +0200 Subject: [PATCH] Allow keyword overrides in nested partial functions Fixes smarie/python-makefun#95. Merge preset keyword defaults before call-time values in regular, generator and async-generator wrappers. Preserve the inner partial. Five regressions fail before the fix. Full suite: 102 passed, 1 skipped on Python 3.12.14; git diff --check clean. No new lint diagnostics relative to the original implementation. Assisted-by: OpenAI Codex --- docs/changelog.md | 6 +++ src/makefun/_main_legacy_py.py | 5 ++- src/makefun/_main_py35_and_higher.py | 5 ++- src/makefun/_main_py36_and_higher.py | 5 ++- src/makefun/main.py | 5 ++- tests/test_generators_coroutines.py | 28 +++++++++++++- tests/test_partial_and_macros.py | 57 ++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 9 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 5d9320f..f5dcb27 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # Changelog +### Unreleased + +- Fixed `partial` so call-time keyword arguments and nested partials can override preset keyword values, + consistently with `functools.partial`. This also applies to generators and async generators. + Fixes [#95](https://github.com/smarie/python-makefun/issues/95). + ### 1.16.0 - Support for 3.14, dropped support for < 3.9 - Removed official support for python versions `<3.9`. These versions will not run in CI anymore. diff --git a/src/makefun/_main_legacy_py.py b/src/makefun/_main_legacy_py.py index 2b87c45..460c7d6 100644 --- a/src/makefun/_main_legacy_py.py +++ b/src/makefun/_main_legacy_py.py @@ -21,8 +21,9 @@ def make_partial_using_yield(new_sig, f, *preset_pos_args, **preset_kwargs): @wraps(f, new_sig=new_sig) def partial_f(*args, **kwargs): # since the signature does the checking for us, no need to check for redundancy. - kwargs.update(preset_kwargs) - gen = f(*chain(preset_pos_args, args), **kwargs) + final_kwargs = preset_kwargs.copy() + final_kwargs.update(kwargs) + gen = f(*chain(preset_pos_args, args), **final_kwargs) _i = iter(gen) # initialize the generator _y = next(_i) # first iteration while 1: diff --git a/src/makefun/_main_py35_and_higher.py b/src/makefun/_main_py35_and_higher.py index df89eda..ac05d05 100644 --- a/src/makefun/_main_py35_and_higher.py +++ b/src/makefun/_main_py35_and_higher.py @@ -19,6 +19,7 @@ def make_partial_using_yield_from(new_sig, f, *preset_pos_args, **preset_kwargs) @wraps(f, new_sig) def partial_f(*args, **kwargs): # since the signature does the checking for us, no need to check for redundancy. - kwargs.update(preset_kwargs) # for python 3.4: explicit dict update - yield from f(*chain(preset_pos_args, args), **kwargs) + final_kwargs = preset_kwargs.copy() + final_kwargs.update(kwargs) + yield from f(*chain(preset_pos_args, args), **final_kwargs) return partial_f diff --git a/src/makefun/_main_py36_and_higher.py b/src/makefun/_main_py36_and_higher.py index c799e33..fcb938e 100644 --- a/src/makefun/_main_py36_and_higher.py +++ b/src/makefun/_main_py36_and_higher.py @@ -19,8 +19,9 @@ def make_partial_using_async_for_in_yield(new_sig, f, *preset_pos_args, **preset @wraps(f, new_sig=new_sig) async def partial_f(*args, **kwargs): - kwargs.update(preset_kwargs) - async for v in f(*chain(preset_pos_args, args), **kwargs): + final_kwargs = preset_kwargs.copy() + final_kwargs.update(kwargs) + async for v in f(*chain(preset_pos_args, args), **final_kwargs): yield v return partial_f diff --git a/src/makefun/main.py b/src/makefun/main.py index e682155..f10413e 100644 --- a/src/makefun/main.py +++ b/src/makefun/main.py @@ -1248,8 +1248,9 @@ def partial(f, # type: Callable @wraps(f, new_sig=new_sig) def partial_f(*args, **kwargs): # since the signature does the checking for us, no need to check for redundancy. - kwargs.update(preset_kwargs) - return f(*itertools.chain(preset_pos_args, args), **kwargs) + final_kwargs = preset_kwargs.copy() + final_kwargs.update(kwargs) + return f(*itertools.chain(preset_pos_args, args), **final_kwargs) # update the doc. # Note that partial_f is generated above with a proper __name__ and __doc__ identical to the wrapped ones diff --git a/tests/test_generators_coroutines.py b/tests/test_generators_coroutines.py index 15f4d6c..2ade24d 100644 --- a/tests/test_generators_coroutines.py +++ b/tests/test_generators_coroutines.py @@ -2,7 +2,7 @@ import pytest -from makefun import with_signature, create_function +from makefun import with_signature, create_function, partial try: from inspect import iscoroutinefunction @@ -115,3 +115,29 @@ async def test_issue_96(): # verify that the new function is a native coroutine and behaves correctly out = await dynamic_fun(0.1) assert out == 0.1 + + +@pytest.mark.parametrize("is_async_generator", [False, True]) +async def test_partial_async_keyword_overrides(is_async_generator): + if is_async_generator: + async def f(a): + yield a + + async def invoke(partial_f, **kwargs): + values = [item async for item in partial_f(**kwargs)] + return values[0] + else: + async def f(a): + return a + + async def invoke(partial_f, **kwargs): + return await partial_f(**kwargs) + + inner = partial(f, a=1) + outer = partial(inner, a=2) + + assert await invoke(outer) == 2 + assert await invoke(inner, a=3) == 3 + assert await invoke(outer, a=4) == 4 + assert await invoke(inner) == 1 + assert await invoke(outer) == 2 diff --git a/tests/test_partial_and_macros.py b/tests/test_partial_and_macros.py index afa8ed6..4e66313 100644 --- a/tests/test_partial_and_macros.py +++ b/tests/test_partial_and_macros.py @@ -211,3 +211,60 @@ def f1(a): # the func attribute is there too f4 = functools.partial(f1) assert f2.func == f4.func + + +@pytest.mark.parametrize("is_generator", [False, True]) +def test_partial_keyword_overrides(is_generator): + """Call-time and nested keyword assignments override partial defaults.""" + if is_generator: + def f(a, b): + yield a, b + + def invoke(partial_f, **kwargs): + return next(partial_f(**kwargs)) + else: + def f(a, b): + return a, b + + def invoke(partial_f, **kwargs): + return partial_f(**kwargs) + + inner = makefun.partial(f, a=1) + outer = makefun.partial(inner, a=2, b=3) + reference = functools.partial(functools.partial(f, a=1), a=2, b=3) + + assert str(signature(outer)) == str(signature(reference)) + assert invoke(outer) == (2, 3) + assert invoke(inner, b=4) == (1, 4) + + call_kwargs = {"a": 5, "b": 6} + assert invoke(inner, **call_kwargs) == (5, 6) + assert invoke(outer, **call_kwargs) == (5, 6) + assert call_kwargs == {"a": 5, "b": 6} + assert invoke(outer) == (2, 3) + + +def test_partial_keyword_override_preserves_positional_arguments(): + def f(a, b, c): + return a, b, c + + inner = makefun.partial(f, 1, c=2) + outer = makefun.partial(inner, 3, c=4) + + assert outer() == (1, 3, 4) + assert outer(c=5) == (1, 3, 5) + assert inner(6) == (1, 6, 2) + + +def test_legacy_generator_partial_keyword_override(): + from makefun._main_legacy_py import make_partial_using_yield + + def f(a): + yield a + + inner = make_partial_using_yield(signature(makefun.partial(f, a=1)), f, a=1) + outer = makefun.partial(inner, a=2) + + assert next(outer()) == 2 + assert next(inner(a=3)) == 3 + assert next(inner()) == 1