3

Considering this abstract class and a class implementing it:

from abc import ABC

class FooBase(ABC):
    foo: str
    bar: str
    baz: int

    def __init__(self):
        self.bar = "bar"
        self.baz = "baz"

class Foo(FooBase):
    foo: str = "hello"

The idea here is that a Foo class that implements FooBase would be required to specify the value of the foo attribute, but the other attributes (bar and baz) would not need to be overwritten, as they're already handle by a method provided by the abstract class.

From a MyPy type-checking perspective, is it possible to force Foo to declare the attribute foo and raise a type-checking error otherwise?

EDIT:

The rationale is that FooBase is part of a library, and the client code should be prevented from implementing it without specifying a value for foo. For bar and baz however, these are entirely managed by the library and the client doesn't care about them.

Jivan
  • 21,522
  • 15
  • 80
  • 131
  • 1
    Do you want `foo` to be a *class* attribute (as assigned by `Foo`) or an *instance* attribute (as assigned by `FooBase.__init__`)? – chepner Jun 19 '22 at 12:49
  • bear in mind that mypy won't check that the subclass calls the superclass's `__init__`, so `bar` and `baz` aren't guaranteed to be initialized either – joel Jun 19 '22 at 13:10
  • @chepner in this case I guess the best is `foo` would be a class attribute? – Jivan Jun 19 '22 at 13:11
  • @joel sure, here I want to focus more on forcing `Foo` to specify a `foo` attribute value – Jivan Jun 19 '22 at 13:12
  • 1
    if it's a class attribute, `bar` and `baz` definitely won't be initialized at the class level in subclasses. But given your recent comment maybe you don't mind ... – joel Jun 19 '22 at 13:12
  • @joel this is fine for `bar` and `baz`, they are handled entirely by the abstract class, which the client is assumed to trust. I edited the question to clarify this. – Jivan Jun 19 '22 at 13:13
  • You still haven't clarified if `foo` is supposed to be a class or instance attribute. It's fine to make `Foo` responsible for setting its value, but if it's supposed to be an instance attribute, you should define `self.foo` in `Foo.__init__` (just like `FooBase` sets the instance attributes `bar` and `bar`), not define `Foo.foo`. – chepner Jun 19 '22 at 16:24
  • @chepner it should be a class attribute — there's always gonna be only one single class `Foo` being instantiated – Jivan Jun 19 '22 at 18:23

3 Answers3

3

Previously, one the "solutions" to this type of problem was to stack @property, @classmethod, and @abstractmethod together to produce an "abstract class property`.

According to CPython issue #89519, chaining descriptor decorators like @classmethod or @staticmethod with @property can behave really poorly, so it has been decided that chaining decorators like this is deprecated beginning Python 3.11, and will now error with tools like mypy.

There is an alternative solution if you really need something that behaves like an abstract class property, as explained in this comment, especially if you need a property for some expensive/delayed accessing. The trick is to supplement using @abstractmethod with subclassing typing.Protocol.

from typing import ClassVar, Protocol


class FooBase(Protocol):
    foo: ClassVar[str]


class Foo(FooBase):
    foo = "hello"


class Bar(FooBase):
    pass


Foo()
Bar()  # Cannot instantiate abstract class "Bar" with abstract attribute "foo"

Note that although linters can catch this type of error, it is not enforced at runtime, unlike creating a subclass of abc.ABC which causes a runtime error if you try to instantiate a class with an abstract property.

Additionally, the above approach does not support the use of foo = Descriptor(), similar to implementing an attribute with a @property instead. To cover both cases, you'll need to use the following:

from typing import Any, ClassVar, Optional, Protocol, Type, TypeVar, Union

T_co = TypeVar("T_co", covariant=True)


class Attribute(Protocol[T]):

    def __get__(self, instance, owner=None) -> T_co:
        ...


class FooBase(Protocol):
    foo: ClassVar[Union[Attribute[str], str]]


class Foo(FooBase):
    foo = "hello"


class Foo:

    def __get__(self, instance: Any, owner: Optional[Type] = None) -> str:
        return "hello"


class Bar(FooBase):
    foo = Foo()


Foo()
Bar()

Both classes pass type checks and actually work at runtime as intended, although again nothing is enforced at runtime.

Simply Beautiful Art
  • 1,284
  • 15
  • 16
2

This is a partial answer. You can use

class FooBase(ABC):
    @property
    @classmethod
    @abstractmethod
    def foo(cls) -> str:
        ...

class Foo(FooBase):
    foo = "hi"

def go(f: FooBase) -> str:
    return f.foo

It's only partial because you'll only get a mypy error if you try to instantiate Foo without an initialized foo, like

class Foo(FooBase):
    ...

Foo()  # error: Cannot instantiate abstract class "Foo" with abstract attribute "foo"

This is the same behaviour as when you have a simple @abstractmethod. Only when instantiating it is the error raised. This is expected because Foo might not be intended as a concrete class, and may itself be subclassed. You can mitigate this somewhat by stating it is a concrete class with typing.final. The following will raise an error on the class itself.

@final
class Foo(FooBase):  # error: Final class __main__.Foo has abstract attributes "foo"
   ...
joel
  • 6,359
  • 2
  • 30
  • 55
  • Interesting, I wasn't aware of this approach so thanks for this. – Jivan Jun 19 '22 at 13:31
  • 1
    @Jivan see my extra comment on `final`, which might help – joel Jun 19 '22 at 14:08
  • This answer no longer works in newer versions of mypy as tested on the mypy playground and explained in [this answer](https://stackoverflow.com/a/75253719), along with work arounds. Worse yet, new versions of Python will **fail at runtime** as well, **breaking** any code which uses this answer. – Simply Beautiful Art Jan 27 '23 at 03:05
0

Rather than relying on the user to set an attribute inside of the class body, you can instead mandate a value using __init_subclass__ as proposed by PEP 487. Additionally, you should use typing.ClassVar for class variables, otherwise it'll mix with instance variables.

from typing import Any, ClassVar


class FooBase:
    foo: ClassVar[str]
    
    def __init_subclass__(cls, /, *, foo: str, **kwargs: Any) -> None:
        cls.foo = foo
        return super().__init_subclass__(**kwargs)


class Foo(FooBase, foo="hello"):
    pass

This syntax is clean for the user, especially when you need something more complex than setting a class variable like

class Database(DB, user=..., password=...):
    pass

which could get setup to create a Database.connection class variable.

The one downside with this approach is that further subclasses will need to continue supplying the class parameters, which can usually be fixed by implementing your own __init_subclass__:

class MainDatabase(DB, user=..., password=...):

    def __init_subclass__(cls, /, reconnect: bool = False, **kwargs: Any) -> None:
        # Create a new connection.
        if reconnect:
            kwargs.setdefault("user", cls.user)
            kwargs.setdefault("password", cls.password)
            return super().__init_subclass__(**kwargs)

        # Re-use the current connection.
        else:
            return super(DB, cls).__init_subclass__(**kwargs)


class SubDatabase(Database, reconnect=True, user=..., password=...):
    pass

Good linters should be able to recognize this type of pattern, producing errors on class Foo(FooBase): without producing errors on class SubDatabse(Database, reconnect=True):.

Simply Beautiful Art
  • 1,284
  • 15
  • 16
  • How would you impolment this logic but using decoratos? for example, force the classes implmenting it to declare a classvar named foo of type string and bar of type int: @with_clasvars(foo:str, bar:int) class FooBase: ... – Bravhek May 11 '23 at 22:59