Skip to content

performance shortcut in functools.partial behaves differently in C and in Python version #100242

Closed
@cfbolz

Description

@cfbolz

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

Linked PRs

Metadata

Metadata

Assignees

Labels

stdlibPython modules in the Lib dirtype-bugAn unexpected behavior, bug, or error

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions