diff --git a/AUTHORS.rst b/AUTHORS.rst index 5f2dd72..3962f66 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 f0a8a47..cc415a7 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. - Forward keyword arguments unchanged through ``over_args``. - 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. diff --git a/src/pydash/functions.py b/src/pydash/functions.py index 4840948..1dc7a43 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 7a2a852..60190dd 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -998,6 +998,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):