From 1304f4ae47bf2ecffacad157850c1a79cf14497e Mon Sep 17 00:00:00 2001 From: Cyber-Marty <15370813896@163.com> Date: Wed, 11 Nov 2026 09:42:19 +0800 Subject: [PATCH] fix(functions): keep partial iteratee arguments out of keyword-bound slots `Partial._argcount` subtracted every keyword binding from the positional count, so a middle parameter bound by keyword still advertised a slot that a caller cannot fill. `map_` then passed the index into that slot and raised `TypeError: got multiple values for argument`. Positional capacity now ends just before the first keyword-bound slot. Slot indices are taken from `argspec.args` after skipping the implicit `self`/`cls` that `getfullargspec` reports but the signature-based `func_argcount` already excludes, so bound methods, classmethods and callable instances keep their previous arity for trailing bindings. Keyword-only parameters, `**kwargs`, varargs-only callables and callables without an inspectable signature keep their existing behavior. Adds regression tests for both partial directions: the reported failure through `map_` and `chain(...).map_()`, arity for trailing/middle/double bindings, implicit first parameters, bound positional arguments with a keyword slot, and the zero-argument case. --- AUTHORS.rst | 1 + CHANGELOG.rst | 3 ++ src/pydash/functions.py | 29 +++++++++++---- tests/test_functions.py | 81 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 5f2dd72b..3962f66e 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -87,3 +87,4 @@ Contributors - Jinyong Li, `Jinyong-Li@github `_ - wynneyin, `wynneyin@github `_ - Arin Aeruva, `arin30@github `_ +- Cyber Marty, `Cyber-Marty@github `_ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7d73f6a0..426304cf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,9 @@ Changelog Unreleased ---------- +- Stop ``partial`` and ``partial_right`` wrappers from accepting a positional iteratee argument in + a slot that a keyword argument already binds, so ``map_`` passes the element instead of an index + into the bound parameter. - Fix bug in ``random`` where float bounds spanning more than the maximum finite float (e.g. ``random(-1e308, 1e308)``) could return ``inf`` or ``nan`` instead of a value within the bounds. - Convert Latin Extended-A letters and remove combining diacritical marks in ``deburr`` diff --git a/src/pydash/functions.py b/src/pydash/functions.py index 6a05fc37..749118e3 100644 --- a/src/pydash/functions.py +++ b/src/pydash/functions.py @@ -639,18 +639,31 @@ def _argcount(self) -> t.Optional[int]: ) if func_argcount is None: return None + argcount = func_argcount - len(self.args) try: argspec = getfullargspec(self.func) except TypeError: - bound_keywords = len(self.kwargs) + argcount -= len(self.kwargs) else: - # Keyword-only parameters and **kwargs do not consume positional slots. - bound_keywords = ( - len(self.kwargs) - if argspec.varargs - else sum(key in argspec.args for key in self.kwargs) - ) - argcount = func_argcount - len(self.args) - bound_keywords + if argspec.varargs: + argcount -= len(self.kwargs) + else: + # Keyword-only parameters and **kwargs do not consume positional slots. + # `argspec.args` may start with an implicit `self`/`cls` slot that + # `func_argcount` already excludes, so align both before indexing slots. + implicit_slots = len(argspec.args) - func_argcount + first_bound_slot = next( + ( + index + for index, name in enumerate(argspec.args[implicit_slots:]) + if name in self.kwargs + ), + None, + ) + if first_bound_slot is not None: + # A positional parameter bound by keyword can't also receive a caller + # argument, so only the slots before it stay positionally reachable. + argcount = min(argcount, first_bound_slot - len(self.args)) return argcount if argcount >= 0 else None diff --git a/tests/test_functions.py b/tests/test_functions.py index 9f31f1dd..60b99fec 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -978,6 +978,87 @@ def transform(value, *, increment): assert _.map_([1, 2], wrapped) == [3, 4] +@parametrize("factory", [_.partial, _.partial_right]) +def test_partial_keyword_bound_middle_argument_as_iteratee(factory): + def transform(value, increment=0, scale=1): + return (value + increment) * scale + + wrapped = factory(transform, increment=2) + + assert wrapped(1) == 3 + assert wrapped._argcount == 1 + assert _.map_([1, 2], wrapped) == [3, 4] + assert _.chain([1, 2]).map_(wrapped).value() == [3, 4] + + +@parametrize("factory", [_.partial, _.partial_right]) +def test_partial_keyword_bound_argument_argcount(factory): + def transform(value, increment=0, scale=1): + return (value + increment) * scale + + assert factory(transform, scale=2)._argcount == 2 + assert factory(transform, increment=2)._argcount == 1 + assert factory(transform, increment=2, scale=3)._argcount == 1 + + +@parametrize("factory", [_.partial, _.partial_right]) +def test_partial_keyword_bound_argument_argcount_with_implicit_first_parameter(factory): + class Parser: + def parse(self, value, base=0, scale=1): + return (value + base) * scale + + @classmethod + def parse_class(cls, value, base=0, scale=1): + return (value + base) * scale + + def __call__(self, value, base=0, scale=1): + return (value + base) * scale + + parser = Parser() + + # The implicit `self`/`cls` that `getfullargspec` reports must not shift the bound slot. + assert factory(parser.parse, scale=2)._argcount == 2 + assert factory(parser.parse, base=2)._argcount == 1 + assert factory(parser, scale=2)._argcount == 2 + assert factory(Parser.parse_class, scale=2)._argcount == 2 + + +def test_partial_keyword_bound_method_as_iteratee(): + class Parser: + def parse(self, value, base=0, scale=1): + return (value + base) * scale + + parser = Parser() + + assert _.map_([1, 2], _.partial(parser.parse, scale=2)) == [2, 6] + assert _.map_([1, 2], _.partial(parser.parse, base=2)) == [3, 4] + + +@parametrize("factory", [_.partial, _.partial_right]) +def test_partial_keyword_bound_argument_argcount_with_bound_positional(factory): + def transform(left, right, increment=0, scale=1): + return (left, right, increment, scale) + + assert factory(transform, 10, increment=5)._argcount == 1 + assert factory(transform, 10, 20, increment=5)._argcount == 0 + + +def test_partial_bound_positional_and_keyword_slot_as_iteratee(): + def transform(left, right, increment=0, scale=1): + return (left, right, increment, scale) + + # Slots before the keyword-bound parameter still receive the element. + assert _.map_([1, 2], _.partial(transform, 10, increment=5)) == [ + (10, 1, 5, 1), + (10, 2, 5, 1), + ] + # A zero argument count calls the wrapper without the element or index. + assert _.map_([1, 2], _.partial(transform, 10, 20, increment=5)) == [ + (10, 20, 5, 1), + (10, 20, 5, 1), + ] + + @parametrize("factory", [_.partial, _.partial_right]) def test_partial_variable_keyword_arguments_as_iteratee(factory): def transform(value, **options):