from typing import Protocol
class Foo(Protocol):
def foo(self, a: str): ...
class Bar(Foo): # No error, even though foo() is not implemented
...
The reason is that Bar is considered an abstract class here, but this is arguably not very obvious. Moreover, the user would only get an error when trying to instantiate Bar.
In this Pyright issue, @erictraut mentions a workaround: @final. This works, but rather inconvenient if subclasses are desired.
Thus, I propose that a @concrete decorator be added, with the following back-of-the-napkin semantics:
When used on a class whose direct base classes include at least one protocol but not Protocol itself or ABC:
If there are any unimplemented members, type checkers must raise an error.
(similar to how one would be raised when @final is used on the same class, or when an abstract class is instantiated)
When used on a class whose direct base classes include Protocol or ABC:
Type checkers must raise an error unconditionally.
(similar to how one would be raised when @final is used on the same class)
When used on a normal class:
The decorator has no effects.
When used on other targets:
Such usages are considered errorneous.
(maybe methods should be allowed?)
(Over)simplification: @concrete is the same as @final but allows for subclasses.
To reiterate the original example:
from typing import Protocol, concrete
class Foo(Protocol):
def foo(self, a: str): ...
@concrete
class Bar(Foo): # error: Foo.foo() is not implemented
...
Sounds useful. Accidentally turning your type into an abstract class is a pretty unfortunate failure mode.
If you have to add a decorator to make subclassing a protocol work correctly anyway, you might as well just use the decorator instead of subclassing the protocol in the first place.[1]
def satisfies(*protocols):
def decorator(cls):
for protocol in protocols:
if not issubclass(cls, protocol):
raise TypeError(f'{cls} does not satisfy protocol {protocol}')
return cls
return decorator
from typing import Protocol, runtime_checkable
@runtime_checkable
class MyProto(Protocol):
def foo(self): ...
@satisfies(MyProto)
class Satisfied: # No error
def foo(self):
return self
@satisfies(MyProto)
class Unsatisfied: # TypeError: ...
pass
You might want to have it skip protocol types that aren’t @runtime_checkable in the issubclass check and leave those to a type checker, or do a different kind of compatibility inspection that doesn’t require issubclass, or do nothing at runtime and just leave it all up to a type checker. Not sure what makes the most sense in the general case, it’s just a thought.
I don’t really have an opinion about @concrete and ABC though.
Honestly, every time I’ve attempted to subclass a protocol type, I’ve re-discovered that it doesn’t work the way I thought it does. Does it end up turning my type into a protocol type? Can I also inherit non-protocol types on the same class? I have to look it up every time. ↩︎
Was going to propose this as well. I think it goes right along with the typing.override decorator. I think it would also be nice if it (optionally) raised a runtime exception just like if you tried to instantiate the class.
I would’ve preferred Protocol to not be subclassable at all. Mixing structural and nominal typing is almost never needed, and makes many things in the python type system very complicated to reason about. This problem that this thread proposes a workaround for, is a good example of that.
But that being said, I realize that we’re way past that point now, as it would completely break collections.abc, for example. So feel free to ignore my rant .
Now on a more constructive note; basedpyright will report an error for your example:
Class "Bar" is implicitly a `Protocol` because it extends a `Protocol` without implementing all abstract symbols. If this is intentional, add `Protocol` or `ABC` to its base classes, or use `metaclass=ABCMeta`.
"Foo.foo" is not implemented (reportImplicitAbstractClass)
But even so, it would probably be better to change the spec to match basedpyright’s behavior here. If for whatever reason that I’m currently unaware of, that would turn out to be a bad idea, then (and only then) I would agree that we should introduce @concrete.