Calling abstract methods

PEP 544 indicates that a type checker should generate an error if a class that explicitly derives from the protocol attempts to call a method through super() if that method is unimplemented in the protocol.

class Proto(Protocol):
    def method(self) -> None:
        ...

class Impl(Proto):
    def method(self) -> None:
        super().method() # Type checker error

This makes sense because the method in the protocol is effectively abstract. Currently, pyright doesn’t emit an error here, but PEP 544 says that it should. Mypy properly emits an error.

My question to the community… Should the same logic apply to methods that are explicitly decorated with @abstractmethod in an ABC? There are two cases to consider:

Case 1: Calling an abstract classmethod or staticmethod.

class BaseClass(ABC):
    @abstractmethod
    @classmethod
    def method(cls) -> None:
        pass

# Should this be an error?
# Pyright currently generates error here, but mypy doesn't.
BaseClass.method() 

Case 2: Calling an abstract instance method through a super() call:

class BaseClass(ABC):
    @abstractmethod
    def method(self) -> None:
        pass

class Subclass(BaseClass):
    def method(self) -> None:
        # Should this be an error?
        # Currently, neither pyright nor mypy generate an error here.
        super().method()
1 Like

I’m inclined to think they both should be errors (although this isn’t a high confidence view and I’d be interested in seeing primer — I’m not a big user of abstract classes or inheritance).

Actually, I’m surprised mypy doesn’t error on the second one. Taking a closer look, mypy does complain in the second case if the return type is not a supertype of None. See mypy Playground (Call to abstract method "method" of "BC2" with trivial body via super() is unsafe)

1 Like

No, that Case 2 example shouldn’t be an error. If the programmer wants that to be an error, they can put raise NotImplementedError in the ABC’s implementation. I have heard of examples where Base Classes are the base class of a complex inheritances hierarchy and each subclass should call to super().method to make sure their sibling classes can do the correct work, although I don’t have any real examples of that.

Here a small mock example:

class Base(ABC):
    @abstractmethod
    def method(self) -> None:
        pass


class A(Base):
    def method(self) -> None:
        super().method()
        print("A")


class B(Base):
    def method(self) -> None:
        super().method()
        print("B")


class C(A, B):
    def method(self) -> None:
        super().method()
        print("C")

Type checkers should have no complaints about this.

1 Like

Thanks for the feedback.

My takeaway is that an @abstractmethod can be implemented or not implemented. If it’s implemented, it’s safe to call. If it’s not implemented, then a type checker should generate an error if it’s called.

A “not implemented” method is one whose body consists of either:

  1. An ellipsis
  2. The statement raise NotImplementedError
    (Either of these can be preceded by a docstring.)
class BaseClass(ABC):
    @abstractmethod
    def method1(self) -> None:
        """ This method is unimplemented """
        ...

    @abstractmethod
    def method2(self) -> None:
        """ This method is unimplemented """
        raise NotImplementedError

    @abstractmethod
    def method3(self) -> None:
        """ This method is implemented """
        pass

    @abstractmethod
    @classmethod
    def cls_method1(cls) -> None:
        """ This class method is unimplemented """
        ...

    @abstractmethod
    @classmethod
    def cls_method2(cls) -> None:
        """ This class method is implemented """
        pass

class Subclass(BaseClass):
    def method1(self) -> None:
        super().method1()  # Type checker error

    def method2(self) -> None:
        super().method2()  # Type checker error

    def method3(self) -> None:
        super().method3()  # OK

BaseClass.cls_method1() # Type checker error
BaseClass.cls_method2() # OK

It’s too late for me to formulate this out, but two more questions to consider:

  • what if the return type doesn’t allow None?
  • what about a body consisting only of a docstring?

what if the return type doesn’t allow None?

That’s fine. If you use a ... or a raise statement within a protocol method or an @abstractmethod, type checkers do not enforce the return type.

what about a body consisting only of a docstring?

Good point, I forgot that case. It should be considered “unimplemented” as well. That’s consistent with how mypy and pyright treat such functions.

So mypy should stop considering pass a trivial body and check the return type? (See the mypy playground gist linked above)

In fact, this is specifically called out in the box at the end of the abc.abstractmethod() docs.

3 Likes

Hm, that is indeed a well-known pattern for multiple inheritance, but I would argue that the base class method should not be declared abstract, since it is clearly expected to be called.

I am surprised that mypy makes a distinction between an abstract superclass method returning None or object and one returning something else. I suspect this is the result of a careful compromise based on observation of a large production code base. (It’s unlikely to be needed by typeshed, otherwise I’d put that in as well.) Unfortunately it appears that the active mypy developers have chosen to ignore this forum.

PS: I seem to be behind the times. I didn’t realize there was a difference between ... and pass as the body of a “trivial” function/method.

2 Likes

OTOH, there is a clear expectation that it should be overwritten and a subclass not doing that is doing the wrong thing :tm:. An alternative solution would be that super().method() somehow doesn’t try to call an abstract method, but that doesn’t sound like a good idea either.

I am also not sure if I like that. This seems like something that will have to be explained again and again… But I can’t think of a better solution either.

I’ve changed pyright to generate an error when calling an unimplemented abstract method (and similarly, an unimplemented method within a protocol). That brings pyright in conformance with the existing typing spec in this respect. It also brings the behavior of pyright and mypy closer together.

There’s still an open question about what precisely constitutes an “unimplemented function”, but that debate can be deferred. I think it may be somewhat contentious. I find mypy’s current behavior to be inconsistent and difficult to justify, which is why I intentionally deviated from it in pyright. I’ve added this to a long list of issues that I think we should eventually tackle in the typing spec. I’m trying to be very deliberate about which issues we tackle first, and this doesn’t strike me as one of the higher-priority items.

1 Like

Oh, I checked the docs, and you’re right – " The abstract methods can be called using any of the normal ‘super’ call mechanisms."

So you’re right. Sorry for the noise.

Yeah, that would be terrible.

We’re still stuck with the unfortunate difference between ... and pass, but when called, both are effectively no-ops.

I didn’t realize there was a difference between ... and pass as the body of a “trivial” function/method.

mypy’s definition of a “not implemented” method includes one where pass is the entire body: mypy/mypy/semanal.py at 761965d260e54f8e150d8bd7ac3ab3efd6503b93 · python/mypy · GitHub

It seems desirable to me to preserve this. Is the suggestion to change this so that it’s easier to support multiple inheritance patterns?

I am surprised that mypy makes a distinction between [return types compatible with empty bodies and those not]

Yeah, it was one of the most disruptive changes in the last few years, so I think folks were eager to minimise impact in cases where it could be argued to be type safe. Context in Handle empty function bodies safely · Issue #2350 · python/mypy · GitHub

It’s trivial to remove this difference though, so we can see what e.g. primer has to say these days: Disallow all super calls to methods with trivial bodies by hauntsaninja · Pull Request #16756 · python/mypy · GitHub

If I am understanding your comment correctly, it would mean that mypy would then complain about the example I provided above. How would that example be written in a way such that mypy doesn’t complain?

(assuming the change in Disallow all super calls to methods with trivial bodies by hauntsaninja · Pull Request #16756 · python/mypy · GitHub is made) maybe:

class Base(ABC):
    @abstractmethod
    def method(self) -> None:
        return

So then there is a difference between empty/.../pass and return, despite all resulting in (almost) identical bytecode. IMO treating pass as non-trivial seems like a cleaner solution. Yes, then there is a difference between empty/... and pass, but that at least seems to be an easier to explain idiom than having an empty return. But I don’t have much of a stake in that discussion tbh.

Yeah, fair enough — the main advantage is that it wouldn’t change well-established behaviour for mypy users. I also think both ... and pass read a little bit more like “dummy code” than return does, but that’s a more marginal and subjective argument.

Please, let’s not further debate what constitutes “unimplemented” in this thread. That issue is orthogonal to the question I wanted to discuss here.

Thanks everyone for your feedback on the primary topic. I think I got a clear answer to my question.

1 Like

Thank you!
Can you please share an ETA/which pyright version is expected to intercept this change?
Looks like it has already been addressed in 1.1.345 , thank you.