Open AdrianSosic opened 3 months ago
cc @AlexWaygood
Okay, I strongly suspect this is because of this delightful part of typing.py
:
I'll investigate more tomorrow.
Thanks for the clear bug report, @AdrianSosic!
Hi @AlexWaygood, just wanted to ask if you could already find some time to figure out what exactly causes the problem? 🙃
Sorry, I haven't had a chance to look at this properly yet. But thanks for the ping. Please feel free to ping me again if I haven't responded in ~2 weeks :-)
@AlexWaygood, as requested: ping ping 😅 don't want to annoy you so feel free to tell me you have no time for it 😊
Thanks, you're not annoying me!
Just lurking around a little bit … (disappears into nowhere)
If I haven't got to it by then, I'll definitely look at this at the PyCon Sprints (week commencing 20th May)
At least two commits changed the behaviour of this snippet between Python 3.11 and Python 3.12:
"default"
; after this commit, the snippet raised TypeError
.TypeError
; after this commit, the snippet printed "protocol"
.In between those two commits, the traceback was as follows:
My feeling is that the new behaviour is probably correct. Calling isinstance()
and issubclass()
against a protocol that isn't decorated with @runtime_checkable
isn't usually allowed, but we make an explicit exception for the functools
module. But if you create an empty protocol -- as you're doing here -- and decorate it with @runtime_checkable
, then you'll see that all objects are considered instances of that protocol. This has been the case since runtime-checkable protocols were originally introduced in Python 3.8:
Python 3.8.18 (default, Feb 15 2024, 19:36:58)
[Clang 15.0.0 (clang-1500.1.0.2.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing import Protocol, runtime_checkable
>>> @runtime_checkable
... class EmptyProtocol(Protocol): pass
...
>>> isinstance([], EmptyProtocol)
True
>>> isinstance(object(), EmptyProtocol)
True
>>> issubclass(dict, EmptyProtocol)
True
>>> issubclass(object, EmptyProtocol)
True
As such, it makes sense that s.register(MyProtocol, lambda x: "protocol")
would lead to lambda x: "protocol"
being selected as the relevant function variant for any arbitrary object being passed in. Non-empty protocols also appear to be behaving correctly:
>>> from functools import singledispatch
>>> s = singledispatch(lambda x: "default")
>>> from typing import SupportsIndex
>>> _ = s.register(SupportsIndex, lambda x: "supports index")
>>> s('foo')
'default'
>>> s({})
'default'
>>> s(432)
'supports index'
So, I think the only thing to do here is to add a regression test, since it appears that there was a bug on Python <=3.11 that was accidentally fixed. @carljm / @JelleZijlstra, do you agree?
I agree that everything should match an empty protocol and therefore the new behavior is correct.
However, it seems wrong that singledispatch allows dispatching on a non-runtime checkable protocol. I tried locally and test_functools
and test_typing
both pass if I remove functools from _allow_reckless_class_checks
. I wonder if we can remove it.
However, it seems wrong that singledispatch allows dispatching on a non-runtime checkable protocol. I tried locally and
test_functools
andtest_typing
both pass if I remove functools from_allow_reckless_class_checks
. I wonder if we can remove it.
I agree that this does seem wrong. However, changing it will break users like @AdrianSosic who have been relying on the existing behaviour where it was allowed. I don't think we can change this without a deprecation period.
Bug report
Bug description:
It seems the behavior of
functools.singledispatch
changed in Python 3.12 in situations where atyping.Protocol
is involved.When executing the following code on 3.12,
MyClass
no longer gets dispatched as via the default but asMyProtocol
. The problem seems to be thatissubclass
now behaves differently when called on protocols. The issue was noticed here where it caused downstream problems.CPython versions tested on:
3.11, 3.12
Operating systems tested on:
macOS