Repository navigation
isinstance on runtime_checkable Protocol has side-effects for @property methods #102433
Description
Activity
- changed the title
[-]isinstance on runtime_checkable Protocol has side-effects for @property methods[/-][+]`isinstance` on `runtime_checkable` `Protocol` has side-effects for `@property` methods[/+]on Mar 5, 2023 Apologies, if this is a duplicate, or the wrong place to raise this, but I couldn't find anything much on this issue outside of https://stackoverflow.com/questions/66641191/rationale-of-instancecheck-on-protocols
Hi @chrisjsewell. I've traced the problem to the
hasattrcall in the_ProtocolMetaclass referencd in that S.O question you linked.hasattrcallsgetattr, which in your test case succeeds, thus executing the code inmyproperty, which then errors. It's quite trivial to solve (we just replace the getattr with checking the existence ofattrindir(instance)).I can't think of any particular downside to making this change so will open a PR shortly:)
Brilliant thanks
@JosephSBoyle, a better solution would probably be to use
inspect.getattr_static, which is specifically designed for solving this kind of problem.Looking in an object's
dir()is problematic, because objects can define custom__dir__methods:>>> class Foo: ... def __init__(self): ... self.x = 5 ... def __dir__(self): ... return [] ... >>> "x" in dir(Foo()) False
A better solution is to look in an object's
__dict__, but there are complications here as well.>>> class Foo: ... a = 1 ... def __init__(self): ... self.b = 2 ... >>> obj = Foo() >>> "a" in obj.__dict__ False >>> "b" in obj.__dict__ True >>> "a" in obj.__class__.__dict__ True >>> "b" in obj.__class__.__dict__ False
(It gets even more complicated if the attribute is defined on a superclass, or if the class defines
__slots__.)inspect.getattr_staticalready tackles all of these complications, so it would provide a better solution than reinventing the wheel here :)Having said that, importing
inspectintyping.pymight slow down importingtypingat runtime, so we should be careful to check that.Reacted by JosephSBoyleThanks @AlexWaygood, I wasn't aware of
getattr_static.The docs suggest that using this might result in changing some other existing result in a change in behaviour, for instance "not being able to fetch dynamically created attributes". I'm not sure if this is wrong, or if I'm misunderstanding what is stated.
Change:
# typing.py if cls._is_protocol: # Check if attr is defined on the instance using `inspect.getattr_static` # to avoid triggering possible side-effects in function style properties # (i.e those with @property decorators) that would occur if `hasattr` # were used. import inspect def hasattr_static(instance, attr): try: inspect.getattr_static(instance, attr) return True except AttributeError: return False if all(hasattr_static(instance, attr) and # All *methods* can be blocked by setting them to None. (not callable(getattr(cls, attr, None)) or getattr(instance, attr) is not None) for attr in _get_protocol_attrs(cls)): return True return super().__instancecheck__(instance)
Test for dynamic attribute checking with
getattr_static:def test_dynamically_added_attribute(self): """Test a class dynamically becoming adherent to a Protocol.""" @runtime_checkable class P(Protocol): @property def foo(self): pass class X: pass x = X() self.assertFalse(isinstance(x, P)) x.foo = "baz" self.assertTrue(isinstance(x, P)) x2 = X() x2.foo = property(lambda self: "baz") self.assertTrue(isinstance(x2, P))
The above test passes, suggesting getattr static can in fact find dynamically added attributes.
I'm not sure if this is wrong, or if I'm misunderstanding what is stated.
I think you're misunderstanding, but the
inspectdocs could probably be clearer on this point :) I believe theinspectdocs are talking about situations with fancy descriptors where attributes are dynamically added in__get__methods — the whole point ofgetattr_staticis that it doesn't call__get__methods, so that is indeed something it would struggle with.This may or may not be what the inspect docs are talking about here, but here's a situation where using
inspect.getattr_staticinstead ofhasattrwould lead to a bad regression that we should try to avoid:>>> from typing import ClassVar, Protocol, runtime_checkable >>> @runtime_checkable ... class HasBar(Protocol): ... @property ... def bar(self) -> int: ... ... >>> class Foo: ... x: ClassVar[bool] = False ... @property ... def bar(self) -> int: ... if self.x: ... return 42 ... raise AttributeError ... >>> import inspect >>> f = Foo() >>> inspect.getattr_static(f, "bar") <property object at 0x11f7b0f48> >>> hasattr(f, 'bar') False >>> if isinstance(f, HasBar): # False with current implementation, True with getattr_static implementation ... y = f.bar # type checkers will assume this is a safe attribute access, but if we used getattr_static, this would be unsafe ...
Reacted by JosephSBoyleThe best solution here may actually be to just add a warning to the docs for
runtime_checkablestating that callingisinstance()against runtime-checkable protocols will result in (possibly expensive) properties being accessed. I don't think there's a way of doing it so that we avoid accessing properties on the object but maintain type-safe behaviour.😢 personally I would say this kind of makes
Protocoland@runtime_checkableunusable in many practical applications, and I will just switch back to usingABCclassesReacted by Richard Brearton and Florian Brucker😢 personally I would say this kind of makes
Protocoland@runtime_checkableunusable in many practical applications, and I will just switch back to usingABCclassesI'm open to suggestions, if anybody can think of a way of improving the implementation here while maintaining type safety! 🙂
Thanks for the example, @AlexWaygood.
I agree it seems like we're unfortunately unable to support both use cases and so should stick with the one we've supported thus far and provide a warning w.r.t the second use case as you suggest.
I'm sorry to hear this makes Protocol unusable for the cases you had in mind @chrisjsewell, if you have a concrete case in mind feel free to open a S.O issue and link it here, I'd be happy to take a look.
Reacted by Alex Waygoodif you have a concrete case in mind fee
class X: def __init__(self): self._is_open = False def __enter__(self): # open some resource self._is_open = True def __exit__(self, *args): # close the resource self._is_open = False @property def x(self): if not self._is_open: raise RuntimeError("cannot access x unless in a context") # fetch x ...
- added a commit that references this issue
on Mar 5, 2023 23 remaining items
Type checkers will assume that the isinstance() guard makes the obj.attr attribute access safe inside the guard.
Does it though?
what about:class A: @property def a(self): ... class B(A): @property def a(self): raise AttributeError isinstance(B(), A)
that passes, then raises
is there any difference?For me, trying to account for
AttributeErrorinside of@propertyseems very niche, compared to the larger issue of side effects, but thats just my viewpointReacted by Mehdi Drissi, Jelle Zijlstra, Shantanu and Richard Brearton- added a commit that references this issue
on Mar 6, 2023 I've opened an issue here, attempting to summarise the discussion, and seeking thoughts from the typing community more broadly:
Reacted by Chris Sewell- added a commit that references this issue
on Mar 9, 2023 - added a commit that references this issue
on Mar 11, 2023 - added 4 commits that reference this issue
on Mar 11, 2023 - added a commit that references this issue
on Apr 2, 2023 #103034 has been merged, meaning that descriptors, properties and
__getattr__methods will no longer be called duringisinstance()checks on runtime-checkable protocols in Python 3.12+. The change introduces some minor backwards incompatibilities, however, so can't be backported to 3.11 and 3.10.It might be possible to backport the change to
typing_extensions, but that should be discussed in thetyping_extensionsrepo, not here :)Thanks for changing my mind on this one!
For example:
will raise the
RuntimeErrorThis is an issue, for example, if
mypropertyis an expensive call, has unwanted side effects, or excepts outside of a context managerLinked PRs
isinstance()checks ontyping.runtime_checkableprotocols #102449isinstance()checks ontyping.runtime_checkableprotocols (GH-102449) #102592isinstance()checks ontyping.runtime_checkableprotocols (GH-102449) #102593inspect.getattr_staticintyping._ProtocolMeta.__instancecheck__#103034