Description
Bug
functools.partial
is implemented in functools.py
and in _functoolsmodule.c
. The former is almost never used, so libraries come to depend on the quirks and corner cases of the C implementation. This is a problem for PyPy, where the Python implementation is the only one as of the most recent PyPy version. Here's one such difference, which was uncovered by the lxml
library. The following code leads to a RecursionError
:
import sys
sys.modules['_functools'] = None # force use of pure python version, if this is commented out it works
from functools import partial
class Builder:
def __call__(self, tag, *children, **attrib):
return (tag, children, attrib)
def __getattr__(self, tag):
return partial(self, tag)
B = Builder()
m = B.m
this is the traceback:
Traceback (most recent call last):
File "/home/cfbolz/projects/cpython/bug.py", line 14, in <module>
m = B.m
^^^
File "/home/cfbolz/projects/cpython/bug.py", line 11, in __getattr__
return partial(self, tag)
^^^^^^^^^^^^^^^^^^
File "/home/cfbolz/projects/cpython/Lib/functools.py", line 287, in __new__
if hasattr(func, "func"):
^^^^^^^^^^^^^^^^^^^^^
File "/home/cfbolz/projects/cpython/bug.py", line 11, in __getattr__
return partial(self, tag)
^^^^^^^^^^^^^^^^^^
File "/home/cfbolz/projects/cpython/Lib/functools.py", line 287, in __new__
if hasattr(func, "func"):
^^^^^^^^^^^^^^^^^^^^^
... and repeated
The problem is the following performance shortcut in partial.__new__
:
class partial:
...
def __new__(cls, func, /, *args, **keywords):
if not callable(func):
raise TypeError("the first argument must be callable")
if hasattr(func, "func"): # <------------------- problem
args = func.args + args
keywords = {**func.keywords, **keywords}
func = func.func
Basically in this case func
is an object where calling hasattr(func, "func")
is not safe. The equivalent C code does this check:
if (Py_TYPE(func)->tp_call == (ternaryfunc)partial_call) {
// The type of "func" might not be exactly the same type object
// as "type", but if it is called using partial_call, it must have the
// same memory layout (fn, args and kw members).
// We can use its underlying function directly and merge the arguments.
partialobject *part = (partialobject *)func;
In particular, it does not simply call hasattr
on func
.
Real World Version
This is not an artificial problem, we discovered this via the class lxml.builder.ElementMaker
. It has a __call__
method implemented. It also has __getattr__
that looks like this:
def __getattr__(self, tag):
return partial(self, tag)
Which yields the above RecursionError
on PyPy.
Solution ideas
One approach would be to file a bug with lxml
, but it is likely that more libraries depend on this behaviour. So I would suggest to change the __new__
Python code to add an isinstance
check, to bring its behaviour closer to that of the C code:
def __new__(cls, func, /, *args, **keywords):
if not callable(func):
raise TypeError("the first argument must be callable")
if isinstance(func, partial) and hasattr(func, "func"):
args = func.args + args
...
I'll open a PR with this approach soon. /cc @mgorny