`@concrete` decorator

A recent Stack Overflow question has this example (formatting and comment mine):

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
  ...

Thoughts?

6 Likes

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.


  1. 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. :slight_smile: ↩︎

1 Like

If the aforementioned logic hasn’t existed, then, yes, I would have gone with @satisfies instead. However:

There should be one-- and preferably only one --obvious way to do it.

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 :slightly_smiling_face:.

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)

See the basedpyright playground for a demo.

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.

2 Likes