How to check that concrete method is respecting type-hinting for an abstract method

3.1k views Asked by At

This is a two-part question, but the second part is dependent on the first part.

For educational purposes, I am trying to implement an abstract base class and test suite for groups (the concept from abstract algebra). Part of the definition of an algebraic group is equivalent to a type constraint, and I want to implement that type constraint on an ABC, and have something complain if the methods on the concrete classes don't conform to that constraint.

I've got a first-pass implementation for this for the group of Boolean values under logical and, but there are at least two things wrong with it, and I'm hoping you can help me fix it.

from __future__ import annotations
from abc import ABC, abstractmethod


class AbsGroup(ABC):
    @abstractmethod
    def op(self, other: AbsGroup) -> AbsGroup:   # <-- Line-of-interest #1
        pass


class Bool(AbsGroup):

    def __init__(self, val="False"):

        if val not in ["True", "False"]:
            raise ValueError("Invalid Bool value %s" % val)

        self.val = val

    def op(self, other):
        """Logical AND"""
        if self.val == "True" and other.val == "True":  # <-- Line-of-interest #2
            return Bool("True")
        return Bool("False")

    def __eq__(self, other):
        return self.val == other.val

    def __repr__(self):
        return self.val

Firstly: Line-of-interest #1 is what's doing the type-constraint work, but the current implementation is wrong. It only checks that the method receives and returns an AbsGroup instance. This could be any AbsGroup instance. I want it to check that for the concrete class it gets inherited by, it receives and returns an instance of that concrete class (so in the case of Bool it receives and returns an instance of Bool). The point of the exercise is to do this in one location, rather than having to set it specifically on each concrete class. I presume this is done with some type-hinting generics that are a little bit deeper than I've yet to delve with regard to type-hinting. How do I do this?

Secondly: how do I check the concrete method is complying with the abstract type hint? The type inspector in my IDE (PyCharm) complains at Line-of-interest #2, because it's expecting other to be of type AbsGroup, which doesn't have a val attribute. This is expected, and would go away if I could figure out the solution to the first problem, but my IDE is the only thing I can find that notices this discrepancy. mypy is silent on the matter by default, as are flake8 and pylint. It's great that PyCharm is on the ball, but if I wanted to incorporate this into a workflow, what command would I have to run that would fail in the event of my concrete method not complying with the abstract signature?

1

There are 1 answers

5
Wombatz On BEST ANSWER

First tip: If mypy doesnt tell you enough, try mypy --strict.

You correctly realized that the type annotation for op in the base class is not restrictive enough and in fact would be incompatible with the child class.

Take a look at this not-working example.

from __future__ import annotations
from abc import ABC, abstractmethod


class AbsGroup(ABC):
    @abstractmethod
    def op(self, other: AbsGroup) -> AbsGroup:
        pass


class Bool(AbsGroup):
    def __init__(self, val: str = "False") -> None:
        self.val = val

    def op(self, other: Bool) -> Bool:
        ...

I annotated op in Bool with the correct type but now mypy complains:

file.py:15: error: Argument 1 of "op" is incompatible with supertype "AbsGroup "; supertype defines the argument type as "AbsGroup"

You have two options: Either make the base annotation even less restrictive (Any) or make your class a Generic one:

from __future__ import annotations
from abc import ABC, abstractmethod

from typing import TypeVar, Generic


T = TypeVar('T')


class AbsGroup(Generic[T], ABC):
    @abstractmethod
    def op(self, other: T) -> T:
        pass

# EDIT: ADDED QUOTES AROUND Bool
class Bool(AbsGroup['Bool']):
    def __init__(self, val: str = "False") -> None:
        self.val = val

    def op(self, other: Bool) -> Bool:
        ...

This involves several steps:

  1. create a type variable T (looks similar to generic type variables in other languages)
  2. let the base class also inherit from Generic[T] making it a generic class
  3. change the op method to take and return a T
  4. let the child class inherit from AbsGroup[Bool] (in C++ this is known as CRTP)

This silences mypy --strict and PyCharm correctly infers the return type of op.

Edit:

The previous child class definition looked like this class Bool(AbsGroup[Bool]): ... without quotes. But this does not work and will throw a NameError when creating the class:

NameError: name 'Bool' is not defined

This is expected behaviour as written in PEP 563.

[...] However, there are APIs in the typing module that use other syntactic constructs of the language, and those will still require working around forward references with string literals. The list includes: [...]

  • base classes:

    class C(Tuple['<type>', '<type>']): ...

So the quotes are still required in this case even though we used the future import.

Just a note: why are you using string symbols for the boolean values? There are already two perfectly working instances called True and False. This will make your code much simpler. E.g. the check in the constructor can be simplified to if type(val) is bool (I would not use isinstance here since you dont want val to be a custom type, probably?).