From d466dc40472da6f4e14e66493375a8c0674b10ab Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 4 Mar 2025 06:56:54 -0500 Subject: [PATCH 1/7] gh-130827: Support typing.Self in singledispatchmethod() --- Lib/functools.py | 36 ++++++++++++++++++++++++------------ Lib/test/test_functools.py | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 70c59b109d9703..9bf547ba8366c4 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -885,15 +885,7 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def singledispatch(func): - """Single-dispatch generic function decorator. - - Transforms a function into a generic function, which can have different - behaviours depending upon the type of its first argument. The decorated - function acts as the default implementation, and additional - implementations can be registered using the register() attribute of the - generic function. - """ +def _singledispatchimpl(func, is_method): # There are many programs that use functools without singledispatch, so we # trade-off making singledispatch marginally slower for the benefit of # making start-up of such applications slightly faster. @@ -963,9 +955,19 @@ def register(cls, func=None): func = cls # only import typing if annotation parsing is necessary - from typing import get_type_hints + from typing import get_type_hints, Self from annotationlib import Format, ForwardRef - argname, cls = next(iter(get_type_hints(func, format=Format.FORWARDREF).items())) + hints_iter = iter(get_type_hints(func, format=Format.FORWARDREF).items()) + argname, cls = next(hints_iter) + if cls is Self: + if not is_method: + raise TypeError( + f"Invalid annotation for {argname!r}. ", + "typing.Self can only be used with singledispatchmethod()" + ) + else: + argname, cls = next(hints_iter) + if not _is_valid_dispatch_type(cls): if _is_union_type(cls): raise TypeError( @@ -1010,6 +1012,16 @@ def wrapper(*args, **kw): update_wrapper(wrapper, func) return wrapper +def singledispatch(func): + """Single-dispatch generic function decorator. + + Transforms a function into a generic function, which can have different + behaviours depending upon the type of its first argument. The decorated + function acts as the default implementation, and additional + implementations can be registered using the register() attribute of the + generic function. + """ + return _singledispatchimpl(func, is_method=False) # Descriptor version class singledispatchmethod: @@ -1023,7 +1035,7 @@ def __init__(self, func): if not callable(func) and not hasattr(func, "__get__"): raise TypeError(f"{func!r} is not callable or a descriptor") - self.dispatcher = singledispatch(func) + self.dispatcher = _singledispatchimpl(func, is_method=True) self.func = func def register(self, cls, method=None): diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index d7404a81c234b3..db6ec2f24632b4 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3362,6 +3362,30 @@ def _(item, arg: bytes) -> str: self.assertEqual(str(Signature.from_callable(A.static_func)), '(item, arg: int) -> str') + def test_typing_self(self): + # gh-130827: typing.Self with singledispatchmethod() didn't work + class Foo: + @functools.singledispatchmethod + def bar(self: typing.Self, arg: int | str) -> int | str: ... + + @bar.register + def _(self: typing.Self, arg: int) -> int: + return arg + + + foo = Foo() + self.assertEqual(foo.bar(42), 42) + + @functools.singledispatch + def test(self: typing.Self, arg: int | str) -> int | str: + pass + # But, it shouldn't work on singledispatch() + with self.assertRaises(TypeError): + @test.register + def silly(self: typing.Self, arg: int | str) -> int | str: + pass + + class CachedCostItem: _cost = 1 From fd7e374da1c8ab4d245bda7efb05bf7b4477f785 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 4 Mar 2025 06:58:01 -0500 Subject: [PATCH 2/7] Add blurb. --- .../next/Library/2025-03-04-06-57-56.gh-issue-130827.XZacmc.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-03-04-06-57-56.gh-issue-130827.XZacmc.rst diff --git a/Misc/NEWS.d/next/Library/2025-03-04-06-57-56.gh-issue-130827.XZacmc.rst b/Misc/NEWS.d/next/Library/2025-03-04-06-57-56.gh-issue-130827.XZacmc.rst new file mode 100644 index 00000000000000..3d51ffba47b284 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-03-04-06-57-56.gh-issue-130827.XZacmc.rst @@ -0,0 +1,2 @@ +Fix :exc:`TypeError` when using :func:`functools.singledispatchmethod` with +a :class:`typing.Self` annotation. From 2e6b46f932b19a3826ac6cfe824a56ce56a47f4e Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 4 Mar 2025 07:12:16 -0500 Subject: [PATCH 3/7] Update Lib/test/test_functools.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_functools.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index db6ec2f24632b4..018cc8f8017783 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3386,7 +3386,6 @@ def silly(self: typing.Self, arg: int | str) -> int | str: pass - class CachedCostItem: _cost = 1 From f571ec767d53c6ca93d9df3c5369bb3a2ccd5aad Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 4 Mar 2025 08:17:11 -0500 Subject: [PATCH 4/7] Update Lib/functools.py Co-authored-by: Tomas R. --- Lib/functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/functools.py b/Lib/functools.py index 9bf547ba8366c4..294bc84cadc81e 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -885,7 +885,7 @@ def _find_impl(cls, registry): match = t return registry.get(match) -def _singledispatchimpl(func, is_method): +def _singledispatchimpl(func, *, is_method): # There are many programs that use functools without singledispatch, so we # trade-off making singledispatch marginally slower for the benefit of # making start-up of such applications slightly faster. From 658d3fe6136418ae126799c5a8c722af18662d36 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 17 Mar 2025 13:11:44 +0000 Subject: [PATCH 5/7] Add some checks for edge cases. --- Lib/functools.py | 37 ++++++++++++++++++++++++++++--------- Lib/test/test_functools.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/Lib/functools.py b/Lib/functools.py index 294bc84cadc81e..617bd3881f7185 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -929,6 +929,32 @@ def _is_valid_dispatch_type(cls): return (_is_union_type(cls) and all(isinstance(arg, type) for arg in get_args(cls))) + def _skip_self_type(argname, cls, hints_iter): + # GH-130827: Methods are sometimes annotated with + # typing.Self. We should skip that when it's a valid type. + from typing import Self + if cls is not Self: + return argname, cls + if not is_method: + # typing.Self is not valid in a normal function + raise TypeError( + f"Invalid annotation for {argname!r}. " + "typing.Self can only be used with singledispatchmethod()" + ) + try: + argname, cls = next(hints_iter) + return argname, cls + except StopIteration: + # The method is one of some invalid edge cases: + # 1. method(self: Self) -> ... + # 2. method(self, weird: Self) -> ... + # 3. method(self: Self, unannotated) -> ... + raise TypeError( + f"Invalid annotation for {argname!r}. " + "typing.Self must be the first annotation and must " + "have a second parameter with an annotation" + ) from None + def register(cls, func=None): """generic_func.register(cls, func) -> func @@ -955,18 +981,11 @@ def register(cls, func=None): func = cls # only import typing if annotation parsing is necessary - from typing import get_type_hints, Self + from typing import get_type_hints from annotationlib import Format, ForwardRef hints_iter = iter(get_type_hints(func, format=Format.FORWARDREF).items()) argname, cls = next(hints_iter) - if cls is Self: - if not is_method: - raise TypeError( - f"Invalid annotation for {argname!r}. ", - "typing.Self can only be used with singledispatchmethod()" - ) - else: - argname, cls = next(hints_iter) + argname, cls = _skip_self_type(argname, cls, hints_iter) if not _is_valid_dispatch_type(cls): if _is_union_type(cls): diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 018cc8f8017783..dc5a71c3c44f00 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3376,15 +3376,48 @@ def _(self: typing.Self, arg: int) -> int: foo = Foo() self.assertEqual(foo.bar(42), 42) + # But, it shouldn't work on singledispatch() @functools.singledispatch def test(self: typing.Self, arg: int | str) -> int | str: pass - # But, it shouldn't work on singledispatch() with self.assertRaises(TypeError): @test.register def silly(self: typing.Self, arg: int | str) -> int | str: pass + # typing.Self cannot be the only annotation + with self.assertRaises(TypeError): + class Foo: + @functools.singledispatchmethod + def bar(self: typing.Self, arg: int | str): + pass + + @bar.register + def _(self: typing.Self, arg): + return arg + + # typing.Self can only be used in the first parameter + with self.assertRaises(TypeError): + class Foo: + @functools.singledispatchmethod + def bar(self, arg: int | str): + pass + + @bar.register + def _(self, arg: typing.Self): + return arg + + # 'self' cannot be the only parameter + with self.assertRaises(TypeError): + class Foo: + @functools.singledispatchmethod + def bar(self: typing.Self, arg: int | str): + pass + + @bar.register + def _(self: typing.Self): + pass + class CachedCostItem: _cost = 1 From 2558b74f4b95e2460a25fd5cbe58a5417acf9ed2 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Mon, 17 Mar 2025 13:13:01 +0000 Subject: [PATCH 6/7] Fix code written with stupid nano. --- Lib/test/test_functools.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index dc5a71c3c44f00..72208499bfa97f 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3408,15 +3408,15 @@ def _(self, arg: typing.Self): return arg # 'self' cannot be the only parameter - with self.assertRaises(TypeError): - class Foo: - @functools.singledispatchmethod - def bar(self: typing.Self, arg: int | str): - pass - - @bar.register - def _(self: typing.Self): - pass + with self.assertRaises(TypeError): + class Foo: + @functools.singledispatchmethod + def bar(self: typing.Self, arg: int | str): + pass + + @bar.register + def _(self: typing.Self): + pass class CachedCostItem: From f61d8a463c4562ab03ae7b36391a3ef1532646f4 Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Wed, 2 Apr 2025 06:34:36 -0400 Subject: [PATCH 7/7] Fix indentation in comment. --- Lib/test/test_functools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index 72208499bfa97f..d1fe3c80148cc0 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -3407,7 +3407,7 @@ def bar(self, arg: int | str): def _(self, arg: typing.Self): return arg - # 'self' cannot be the only parameter + # 'self' cannot be the only parameter with self.assertRaises(TypeError): class Foo: @functools.singledispatchmethod