Skip to content

Commit

Permalink
gh-119127: functools.partial placeholders (gh-119827)
Browse files Browse the repository at this point in the history
  • Loading branch information
dg-pb authored Sep 26, 2024
1 parent 4defb58 commit d929652
Show file tree
Hide file tree
Showing 8 changed files with 681 additions and 129 deletions.
72 changes: 59 additions & 13 deletions Doc/library/functools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,14 @@ The :mod:`functools` module defines the following functions:
Returning ``NotImplemented`` from the underlying comparison function for
unrecognised types is now supported.

.. data:: Placeholder

A singleton object used as a sentinel to reserve a place
for positional arguments when calling :func:`partial`
and :func:`partialmethod`.

.. versionadded:: 3.14

.. function:: partial(func, /, *args, **keywords)

Return a new :ref:`partial object<partial-objects>` which when called
Expand All @@ -338,26 +346,67 @@ The :mod:`functools` module defines the following functions:
Roughly equivalent to::

def partial(func, /, *args, **keywords):
def newfunc(*fargs, **fkeywords):
newkeywords = {**keywords, **fkeywords}
return func(*args, *fargs, **newkeywords)
def newfunc(*more_args, **more_keywords):
keywords_union = {**keywords, **more_keywords}
return func(*args, *more_args, **keywords_union)
newfunc.func = func
newfunc.args = args
newfunc.keywords = keywords
return newfunc

The :func:`partial` is used for partial function application which "freezes"
The :func:`partial` function is used for partial function application which "freezes"
some portion of a function's arguments and/or keywords resulting in a new object
with a simplified signature. For example, :func:`partial` can be used to create
a callable that behaves like the :func:`int` function where the *base* argument
defaults to two:
defaults to ``2``:

.. doctest::

>>> from functools import partial
>>> basetwo = partial(int, base=2)
>>> basetwo.__doc__ = 'Convert base 2 string to an int.'
>>> basetwo('10010')
18

If :data:`Placeholder` sentinels are present in *args*, they will be filled first
when :func:`partial` is called. This allows custom selection of positional arguments
to be pre-filled when constructing a :ref:`partial object <partial-objects>`.

If :data:`!Placeholder` sentinels are present, all of them must be filled at call time:

.. doctest::

>>> say_to_world = partial(print, Placeholder, Placeholder, "world!")
>>> say_to_world('Hello', 'dear')
Hello dear world!

Calling ``say_to_world('Hello')`` would raise a :exc:`TypeError`, because
only one positional argument is provided, while there are two placeholders
in :ref:`partial object <partial-objects>`.

Successive :func:`partial` applications fill :data:`!Placeholder` sentinels
of the input :func:`partial` objects with new positional arguments.
A place for positional argument can be retained by inserting new
:data:`!Placeholder` sentinel to the place held by previous :data:`!Placeholder`:

.. doctest::

>>> from functools import partial, Placeholder as _
>>> remove = partial(str.replace, _, _, '')
>>> message = 'Hello, dear dear world!'
>>> remove(message, ' dear')
'Hello, world!'
>>> remove_dear = partial(remove, _, ' dear')
>>> remove_dear(message)
'Hello, world!'
>>> remove_first_dear = partial(remove_dear, _, 1)
>>> remove_first_dear(message)
'Hello, dear world!'

Note, :data:`!Placeholder` has no special treatment when used for keyword
argument of :data:`!Placeholder`.

.. versionchanged:: 3.14
Added support for :data:`Placeholder` in positional arguments.

.. class:: partialmethod(func, /, *args, **keywords)

Expand Down Expand Up @@ -742,10 +791,7 @@ have three read-only attributes:
The keyword arguments that will be supplied when the :class:`partial` object is
called.

:class:`partial` objects are like :ref:`function objects <user-defined-funcs>`
in that they are callable, weak referenceable, and can have attributes.
There are some important differences. For instance, the
:attr:`~function.__name__` and :attr:`function.__doc__` attributes
are not created automatically. Also, :class:`partial` objects defined in
classes behave like static methods and do not transform into bound methods
during instance attribute look-up.
:class:`partial` objects are like :class:`function` objects in that they are
callable, weak referenceable, and can have attributes. There are some important
differences. For instance, the :attr:`~definition.__name__` and :attr:`__doc__` attributes
are not created automatically.
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,15 @@ Added support for converting any objects that have the
(Contributed by Serhiy Storchaka in :gh:`82017`.)


functools
---------

* Added support to :func:`functools.partial` and
:func:`functools.partialmethod` for :data:`functools.Placeholder` sentinels
to reserve a place for positional arguments.
(Contributed by Dominykas Grigonis in :gh:`119127`.)


http
----

Expand Down
192 changes: 133 additions & 59 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@
# Written by Nick Coghlan <ncoghlan at gmail.com>,
# Raymond Hettinger <python at rcn.com>,
# and Łukasz Langa <lukasz at langa.pl>.
# Copyright (C) 2006-2013 Python Software Foundation.
# Copyright (C) 2006-2024 Python Software Foundation.
# See C source code for _functools credits/copyright

__all__ = ['update_wrapper', 'wraps', 'WRAPPER_ASSIGNMENTS', 'WRAPPER_UPDATES',
'total_ordering', 'cache', 'cmp_to_key', 'lru_cache', 'reduce',
'partial', 'partialmethod', 'singledispatch', 'singledispatchmethod',
'cached_property']
'cached_property', 'Placeholder']

from abc import get_cache_token
from collections import namedtuple
# import types, weakref # Deferred to single_dispatch()
from operator import itemgetter
from reprlib import recursive_repr
from types import MethodType
from _thread import RLock
Expand Down Expand Up @@ -274,43 +275,125 @@ def reduce(function, sequence, initial=_initial_missing):
### partial() argument application
################################################################################

# Purely functional, no descriptor behaviour
class partial:
"""New function with partial application of the given arguments
and keywords.

class _PlaceholderType:
"""The type of the Placeholder singleton.
Used as a placeholder for partial arguments.
"""
__instance = None
__slots__ = ()

def __init_subclass__(cls, *args, **kwargs):
raise TypeError(f"type '{cls.__name__}' is not an acceptable base type")

__slots__ = "func", "args", "keywords", "__dict__", "__weakref__"
def __new__(cls):
if cls.__instance is None:
cls.__instance = object.__new__(cls)
return cls.__instance

def __repr__(self):
return 'Placeholder'

def __new__(cls, func, /, *args, **keywords):
def __reduce__(self):
return 'Placeholder'

Placeholder = _PlaceholderType()

def _partial_prepare_merger(args):
if not args:
return 0, None
nargs = len(args)
order = []
j = nargs
for i, a in enumerate(args):
if a is Placeholder:
order.append(j)
j += 1
else:
order.append(i)
phcount = j - nargs
merger = itemgetter(*order) if phcount else None
return phcount, merger

def _partial_new(cls, func, /, *args, **keywords):
if issubclass(cls, partial):
base_cls = partial
if not callable(func):
raise TypeError("the first argument must be callable")
else:
base_cls = partialmethod
# func could be a descriptor like classmethod which isn't callable
if not callable(func) and not hasattr(func, "__get__"):
raise TypeError(f"the first argument {func!r} must be a callable "
"or a descriptor")
if args and args[-1] is Placeholder:
raise TypeError("trailing Placeholders are not allowed")
if isinstance(func, base_cls):
pto_phcount = func._phcount
tot_args = func.args
if args:
tot_args += args
if pto_phcount:
# merge args with args of `func` which is `partial`
nargs = len(args)
if nargs < pto_phcount:
tot_args += (Placeholder,) * (pto_phcount - nargs)
tot_args = func._merger(tot_args)
if nargs > pto_phcount:
tot_args += args[pto_phcount:]
phcount, merger = _partial_prepare_merger(tot_args)
else: # works for both pto_phcount == 0 and != 0
phcount, merger = pto_phcount, func._merger
keywords = {**func.keywords, **keywords}
func = func.func
else:
tot_args = args
phcount, merger = _partial_prepare_merger(tot_args)

self = object.__new__(cls)
self.func = func
self.args = tot_args
self.keywords = keywords
self._phcount = phcount
self._merger = merger
return self

def _partial_repr(self):
cls = type(self)
module = cls.__module__
qualname = cls.__qualname__
args = [repr(self.func)]
args.extend(map(repr, self.args))
args.extend(f"{k}={v!r}" for k, v in self.keywords.items())
return f"{module}.{qualname}({', '.join(args)})"

if isinstance(func, partial):
args = func.args + args
keywords = {**func.keywords, **keywords}
func = func.func
# Purely functional, no descriptor behaviour
class partial:
"""New function with partial application of the given arguments
and keywords.
"""

self = super(partial, cls).__new__(cls)
__slots__ = ("func", "args", "keywords", "_phcount", "_merger",
"__dict__", "__weakref__")

self.func = func
self.args = args
self.keywords = keywords
return self
__new__ = _partial_new
__repr__ = recursive_repr()(_partial_repr)

def __call__(self, /, *args, **keywords):
phcount = self._phcount
if phcount:
try:
pto_args = self._merger(self.args + args)
args = args[phcount:]
except IndexError:
raise TypeError("missing positional arguments "
"in 'partial' call; expected "
f"at least {phcount}, got {len(args)}")
else:
pto_args = self.args
keywords = {**self.keywords, **keywords}
return self.func(*self.args, *args, **keywords)

@recursive_repr()
def __repr__(self):
cls = type(self)
qualname = cls.__qualname__
module = cls.__module__
args = [repr(self.func)]
args.extend(repr(x) for x in self.args)
args.extend(f"{k}={v!r}" for (k, v) in self.keywords.items())
return f"{module}.{qualname}({', '.join(args)})"
return self.func(*pto_args, *args, **keywords)

def __get__(self, obj, objtype=None):
if obj is None:
Expand All @@ -332,6 +415,10 @@ def __setstate__(self, state):
(namespace is not None and not isinstance(namespace, dict))):
raise TypeError("invalid partial state")

if args and args[-1] is Placeholder:
raise TypeError("trailing Placeholders are not allowed")
phcount, merger = _partial_prepare_merger(args)

args = tuple(args) # just in case it's a subclass
if kwds is None:
kwds = {}
Expand All @@ -344,53 +431,40 @@ def __setstate__(self, state):
self.func = func
self.args = args
self.keywords = kwds
self._phcount = phcount
self._merger = merger

try:
from _functools import partial
from _functools import partial, Placeholder, _PlaceholderType
except ImportError:
pass

# Descriptor version
class partialmethod(object):
class partialmethod:
"""Method descriptor with partial application of the given arguments
and keywords.
Supports wrapping existing descriptors and handles non-descriptor
callables as instance methods.
"""

def __init__(self, func, /, *args, **keywords):
if not callable(func) and not hasattr(func, "__get__"):
raise TypeError("{!r} is not callable or a descriptor"
.format(func))

# func could be a descriptor like classmethod which isn't callable,
# so we can't inherit from partial (it verifies func is callable)
if isinstance(func, partialmethod):
# flattening is mandatory in order to place cls/self before all
# other arguments
# it's also more efficient since only one function will be called
self.func = func.func
self.args = func.args + args
self.keywords = {**func.keywords, **keywords}
else:
self.func = func
self.args = args
self.keywords = keywords

def __repr__(self):
cls = type(self)
module = cls.__module__
qualname = cls.__qualname__
args = [repr(self.func)]
args.extend(map(repr, self.args))
args.extend(f"{k}={v!r}" for k, v in self.keywords.items())
return f"{module}.{qualname}({', '.join(args)})"
__new__ = _partial_new
__repr__ = _partial_repr

def _make_unbound_method(self):
def _method(cls_or_self, /, *args, **keywords):
phcount = self._phcount
if phcount:
try:
pto_args = self._merger(self.args + args)
args = args[phcount:]
except IndexError:
raise TypeError("missing positional arguments "
"in 'partialmethod' call; expected "
f"at least {phcount}, got {len(args)}")
else:
pto_args = self.args
keywords = {**self.keywords, **keywords}
return self.func(cls_or_self, *self.args, *args, **keywords)
return self.func(cls_or_self, *pto_args, *args, **keywords)
_method.__isabstractmethod__ = self.__isabstractmethod__
_method.__partialmethod__ = self
return _method
Expand Down
Loading

0 comments on commit d929652

Please sign in to comment.