Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-119127: functools.partial placeholders #119827

Merged
merged 78 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from 70 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
ee7333c
Initial Implementation
dg-pb May 21, 2024
8bcc462
serialization fix
dg-pb May 21, 2024
c67c9b4
bug fix
dg-pb May 21, 2024
680d900
Bug 2 fix
dg-pb May 21, 2024
9591ff5
Py_TPFLAGS_IMMUTABLETYPE added
dg-pb May 21, 2024
067e938
placeholder added to state as opposed to being used as global constant
dg-pb May 23, 2024
8af20b3
static removed
dg-pb May 23, 2024
607a0b1
creating sentinel via PyType_Spec
dg-pb May 23, 2024
f55801e
more accurate variable name
dg-pb May 23, 2024
5894145
trailing trim bug and tests
dg-pb May 24, 2024
3722e07
Updated docs
dg-pb May 24, 2024
a79c2af
blurb
dg-pb May 24, 2024
12aaa72
Merge branch 'main' into implement-119127
May 24, 2024
92c767b
minor edit
dg-pb May 24, 2024
496a9d2
doc fix
dg-pb May 24, 2024
38d9c11
better variable names and mini corrections
dg-pb May 31, 2024
707b957
Merge branch 'implement-119127' into implement-119127-again
dg-pb May 31, 2024
14b38ca
review comments mostly
dg-pb Jun 8, 2024
32bca19
singleton sentinel and reduce
dg-pb Jun 8, 2024
8576493
python module sentinel better mimics the one of the extension
dg-pb Jun 8, 2024
a3fd2d6
Emulated None behaviour, but using PyType_FromModuleAndSpec
dg-pb Jun 8, 2024
0852993
review feedback
dg-pb Jun 8, 2024
6fea348
included constant into tsv
dg-pb Jun 8, 2024
caec6e8
documentation update
dg-pb Jun 8, 2024
115b8c5
review edits
dg-pb Jun 9, 2024
3f5f00b
trailing placeholder prohibition and small changes
dg-pb Jun 9, 2024
202c929
change constant name in ignores
dg-pb Jun 9, 2024
2c16d38
PlaceholderType Hidden
dg-pb Jun 11, 2024
400ff55
support 4-arg pre-placeholder state
dg-pb Jun 20, 2024
8ccc38f
better variable names
dg-pb Jun 20, 2024
e7c82c7
partialmethod impl
dg-pb Jun 20, 2024
c9b7ef3
fix tests
dg-pb Jun 20, 2024
e59d711
adjust inspect to partial Placeholders
dg-pb Jun 20, 2024
7bfc591
arg alignment
dg-pb Jun 20, 2024
7957a97
small fixes
dg-pb Jun 21, 2024
8aaee6a
pickle compatibility ensured
dg-pb Jun 23, 2024
fe8e0ad
trailing placeholder test for __setstate__
dg-pb Jun 23, 2024
00dd80e
rough implementation rolled back and example of successive applicatio…
dg-pb Jun 24, 2024
d352cfa
small doc edits
dg-pb Jun 24, 2024
9038ed5
example changes
dg-pb Jun 24, 2024
49b8c71
serialization issues addressed
dg-pb Jun 25, 2024
bc1fdbd
delete redundant references
dg-pb Jun 25, 2024
3067221
simplify PyPlaceholder implementation
dg-pb Jun 25, 2024
1185510
more optimal python functools.partial
dg-pb Jun 25, 2024
266b4fa
placeholder arg and pre-placeholder instance conversions to positional
dg-pb Jun 25, 2024
dd58a12
unittest.mock.ANY test for Placeholder
dg-pb Jun 25, 2024
5971fbb
functools.partial.__get__
dg-pb Jun 26, 2024
9033650
Revert "functools.partial.__get__"
dg-pb Jun 26, 2024
d31e5d1
Merge branch 'main' into implement-119127-again
dg-pb Jun 26, 2024
a3d39b0
review changes
dg-pb Jun 26, 2024
9e4c5df
factor out repr. same to be used for partial and partialmethod
dg-pb Jun 27, 2024
16f12f8
microopt & args.count(Placeholder) can not be used as it uses __eq__
dg-pb Jun 27, 2024
82dd600
simplify preparation
dg-pb Jun 27, 2024
f9cb653
whatsnew + minor doc edit
dg-pb Jun 27, 2024
d255524
typo fixes
dg-pb Jun 27, 2024
404044e
whatsnew edit
dg-pb Jun 27, 2024
800217b
revert stylistic changes
dg-pb Jun 27, 2024
38ee450
factor out full __new__
dg-pb Jun 28, 2024
11f47db
Merge branch 'main' into implement-119127-again
serhiy-storchaka Jul 3, 2024
3c872bd
Merge branch 'main' into implement-119127-again
serhiy-storchaka Aug 11, 2024
fd16189
CR part 1
dg-pb Aug 12, 2024
a6c6ef2
CR Part 2
dg-pb Aug 12, 2024
1c8d73e
remove ignored global var
dg-pb Aug 12, 2024
a8bd3ae
CR changes
dg-pb Aug 12, 2024
70e47ed
small CR changes and doc updates
dg-pb Aug 12, 2024
2eacf5e
push placeholder check to earlier place
dg-pb Aug 12, 2024
f78d8d3
more appropriate test functions and better doc example
dg-pb Aug 13, 2024
0a8640e
minor fixes; message, doc polish
dg-pb Aug 13, 2024
6e3d282
better doc example and small test changes
dg-pb Aug 13, 2024
66c305d
assertRaisesRegex exact match
dg-pb Aug 14, 2024
14bf68c
doc nits
dg-pb Aug 14, 2024
ee642d5
Add Placeholder to __all__
rhettinger Sep 25, 2024
8d6c28e
Update copyright span
rhettinger Sep 25, 2024
8744bcb
Minor doc edits and add doctests
rhettinger Sep 25, 2024
b896470
Merge branch 'main' into implement-119127-again
rhettinger Sep 25, 2024
4881ae6
Merge branch 'main' into implement-119127-again
rhettinger Sep 25, 2024
c3ad7d9
Having the separate dict was necessary to eliminate duplicate keywords
rhettinger Sep 26, 2024
5e5d484
Merge branch 'main' into implement-119127-again
rhettinger Sep 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion Doc/library/functools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ The :mod:`functools` module defines the following functions:

The :func:`partial` 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
with a simplified signature. For example, :func:`partial` can be used to create
dg-pb marked this conversation as resolved.
Show resolved Hide resolved
a callable that behaves like the :func:`int` function where the *base* argument
defaults to two:

Expand All @@ -358,6 +358,49 @@ The :mod:`functools` module defines the following functions:
>>> 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:

>>> from functools import partial, Placeholder
dg-pb marked this conversation as resolved.
Show resolved Hide resolved
picnixz marked this conversation as resolved.
Show resolved Hide resolved
>>> 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`:

>>> 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.

.. data:: Placeholder

A singleton object used as a sentinel to reserve a place
for positional arguments when calling :func:`partial`
and :func:`partialmethod`.
dg-pb marked this conversation as resolved.
Show resolved Hide resolved

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

Expand Down
8 changes: 8 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ Added support for converting any objects that have the
:meth:`!as_integer_ratio` method to a :class:`~fractions.Fraction`.
(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`.)

json
----

Expand Down
188 changes: 131 additions & 57 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from abc import get_cache_token
from collections import namedtuple
# import types, weakref # Deferred to single_dispatch()
from operator import itemgetter
dg-pb marked this conversation as resolved.
Show resolved Hide resolved
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
dg-pb marked this conversation as resolved.
Show resolved Hide resolved
__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
Comment on lines +290 to +293
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is overcomplication. The user has no reasons to create an instance of private class _PlaceholderType. And if they need, they can do it anyway by using object.__new__(_PlaceholderType).

If you want to add some guards here, just make __new__ always raising an exception and create the single instance as object.__new__(_PlaceholderType).

Copy link
Contributor Author

@dg-pb dg-pb Aug 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was to mimic C implementation, which has the behaviour of None.

I think the question is: "Is it a good practice for a non-trivial sentinel to be singleton, i.e. type(None)() is None.

If yes and this sentinel is considered non-trivial, then this is as good as it can get for now and protection issues can be sorted out together with further developments in this area.

If no, then this needs to be changed for both C and Python.

@rhettinger has suggested this initially and I like this behaviour (and adapted it to my own sentinels). It would be good if you together could come to agreement before I make any further changes here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not required for its main function, and this complicates both implementations. It is better to implement only necessary parts. If later we will find a need of this feature, it will be easier to add it than to remove it.

Strictly speaking, making the Placeholder class non-inheritable and non-instantiable is not required. But it is easy to implement.

I hope Raymond will change his opinion on this.

Copy link
Contributor Author

@dg-pb dg-pb Aug 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you are saying makes sense, but at the same time I like current behaviour and if sentinels were standardised and their creation was made more convenient I think this elegant behaviour would be nice to get by default.

I am neutral by now on this specific case. Well, slightly negative just because I put thought and effort into this and simply like it.


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")
dg-pb marked this conversation as resolved.
Show resolved Hide resolved
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
24 changes: 22 additions & 2 deletions Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -1932,7 +1932,12 @@ def _signature_get_partial(wrapped_sig, partial, extra_args=()):
if param.kind is _POSITIONAL_ONLY:
# If positional-only parameter is bound by partial,
# it effectively disappears from the signature
new_params.pop(param_name)
# However, if it is a Placeholder it is not removed
# And also looses default value
if arg_value is functools.Placeholder:
new_params[param_name] = param.replace(default=_empty)
else:
new_params.pop(param_name)
continue

if param.kind is _POSITIONAL_OR_KEYWORD:
Expand All @@ -1954,7 +1959,17 @@ def _signature_get_partial(wrapped_sig, partial, extra_args=()):
new_params[param_name] = param.replace(default=arg_value)
else:
# was passed as a positional argument
new_params.pop(param.name)
# Do not pop if it is a Placeholder
# also change kind to positional only
# and remove default
if arg_value is functools.Placeholder:
dg-pb marked this conversation as resolved.
Show resolved Hide resolved
new_param = param.replace(
kind=_POSITIONAL_ONLY,
default=_empty
)
new_params[param_name] = new_param
else:
new_params.pop(param_name)
continue

if param.kind is _KEYWORD_ONLY:
Expand Down Expand Up @@ -2448,6 +2463,11 @@ def _signature_from_callable(obj, *,
sig_params = tuple(sig.parameters.values())
assert (not sig_params or
first_wrapped_param is not sig_params[0])
# If there were placeholders set,
# first param is transformed to positional only
if partialmethod.args.count(functools.Placeholder):
first_wrapped_param = first_wrapped_param.replace(
kind=Parameter.POSITIONAL_ONLY)
new_params = (first_wrapped_param,) + sig_params
return sig.replace(parameters=new_params)

Expand Down
Loading
Loading