Skip to content

gh-85294: Handle missing arguments to @singledispatchmethod gracefully #21471

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

Merged
merged 7 commits into from
Feb 16, 2024

Conversation

ammaraskar
Copy link
Member

@ammaraskar ammaraskar commented Jul 14, 2020

This prevents a confusing error message when you fail to pass an argument. The code mirrors the check already present in singledispatch.

Before

>>> import functools
>>> class A:
...   @functools.singledispatchmethod
...   def t(self):
...     pass
...   @t.register
...   def _(self, arg: int): return "int"
...
>>> A().t()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cpython\lib\functools.py", line 914, in _method
    method = self.dispatcher.dispatch(args[0].__class__)
IndexError: tuple index out of range

After

>>> import functools
>>> class A:
...   @functools.singledispatchmethod
...   def t(self):
...     pass
...   @t.register
...   def _(self, arg: int):
...     return "int"
...
>>> A().t()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cpython\lib\functools.py", line 916, in _method
    raise TypeError(f'{self.dispatcher._funcname} requires at least '
TypeError: t requires at least 1 positional argument

https://bugs.python.org/issue41122

Copy link

@nagarajan nagarajan left a comment

Choose a reason for hiding this comment

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

I am not the assigned reviewer, but was just looking through open PRs (so my review has little weight)

Your changes look good!! 👍

Just added one minor comment for changing the test method name.

Co-authored-by: nagarajan <68099336+nagarajan@users.noreply.github.com>
Copy link

@nagarajan nagarajan left a comment

Choose a reason for hiding this comment

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

Looks good!

@changeling
Copy link

Any word on this? I've adapted to recognizing the error pattern, but this fix would have saved me some time, and I imagine it's costing time for others.

Copy link
Contributor

@mental32 mental32 left a comment

Choose a reason for hiding this comment

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

I accidentally opened a duplicate PR for this since I ran into it today and it wasn't pleasant to figure out, +1.

@rsdenijs
Copy link

It seems this PR has been forgotten. I ran into this recently and also took me some time to figure out. It would be nice to merge it.

@serhiy-storchaka serhiy-storchaka changed the title bpo-41122: Handle missing arguments to @singledispatchmethod gracefully gh-85294: Handle missing arguments to @singledispatchmethod gracefully Feb 16, 2024
@serhiy-storchaka serhiy-storchaka enabled auto-merge (squash) February 16, 2024 18:27
Comment on lines +972 to 975
if not args:
raise TypeError(f'{funcname} requires at least '
'1 positional argument')
return dispatch(args[0].__class__).__get__(obj, cls)(*args, **kwargs)
Copy link
Member

Choose a reason for hiding this comment

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

I haven't done benchmarks, but is there a risk that this could add unnecessary overhead to the happy path? What if we did something like this @serhiy-storchaka?

Suggested change
if not args:
raise TypeError(f'{funcname} requires at least '
'1 positional argument')
return dispatch(args[0].__class__).__get__(obj, cls)(*args, **kwargs)
try:
key = args[0].__class__
except IndexError:
raise TypeError(f'{funcname} requires at least '
'1 positional argument') from None
return dispatch(key).__get__(obj, cls)(*args, **kwargs)

Copy link
Member

Choose a reason for hiding this comment

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

It looks reasonable, but could you please benchmark this? Your code is not zero cost either, it adds STORE_FAST+LOAD_FAST instead of LOAD_FAST+TO_BOOL+POP_JUMP_IF_TRUE.

If there is a difference, we perhaps should apply the same optimization in singledispatch().

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I'll benchmark it.

Copy link
Member

Choose a reason for hiding this comment

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

For now, I reverted this change because it complicates the code. And the solution for other issues may change it anyway. It is better to defer it until the code will be stabilized.

serhiy-storchaka and others added 2 commits February 16, 2024 20:39
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
@AlexWaygood
Copy link
Member

I measure a small, but significant, speedup for the following patch (relative to your PR branch) when @singledispatchmethod is used on slotted classes. Note that funcname is only calculated in the except IndexError block:

--- a/Lib/functools.py
+++ b/Lib/functools.py
@@ -967,12 +967,14 @@ def __get__(self, obj, cls=None):
                 return _method
 
         dispatch = self.dispatcher.dispatch
-        funcname = getattr(self.func, '__name__', 'singledispatchmethod method')
         def _method(*args, **kwargs):
-            if not args:
+            try:
+                key = args[0].__class__
+            except IndexError:
+                funcname = getattr(self.func, '__name__', 'singledispatchmethod method')
                 raise TypeError(f'{funcname} requires at least '
-                                '1 positional argument')
-            return dispatch(args[0].__class__).__get__(obj, cls)(*args, **kwargs)
+                                '1 positional argument') from None
+            return dispatch(key).__get__(obj, cls)(*args, **kwargs)

Benchmark script:

import pyperf

runner = pyperf.Runner()
runner.timeit(name="bench singledispatchmethod",
              stmt="""
_ = t.go(1, 1)
""",
setup="""
from functools import singledispatch, singledispatchmethod

class Test:
    __slots__ = ()

    @singledispatchmethod
    def go(self, item, arg):
        pass

    @go.register
    def _(self, item: int, arg):
        return item + arg

t = Test()
""")

I could not measure any performance difference from my original suggestion at #21471 (comment).

Numbers from that benchmark:

  • With this PR currently:
    bench singledispatchmethod: Mean +- std dev: 824 ns +- 8 ns
    
  • With my proposed patch added to this PR:
    bench singledispatchmethod: Mean +- std dev: 810 ns +- 20 ns
    

I'm fine with it if you want to defer optimising this area of code for now; I agree there are lots of open bug reports regarding @singledispatchmethod, and that those should take priority.

@serhiy-storchaka serhiy-storchaka merged commit 8b776e0 into python:main Feb 16, 2024
woodruffw pushed a commit to woodruffw-forks/cpython that referenced this pull request Mar 4, 2024
…cefully (pythonGH-21471)

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
diegorusso pushed a commit to diegorusso/cpython that referenced this pull request Apr 17, 2024
…cefully (pythonGH-21471)

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
LukasWoodtli pushed a commit to LukasWoodtli/cpython that referenced this pull request Jan 22, 2025
…cefully (pythonGH-21471)

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants