Reputation: 23068
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.
Upvotes: 6
Views: 6393
Reputation: 1749
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.
Upvotes: 9
Reputation: 1749
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):
.
Upvotes: 1
Reputation: 7868
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"
...
Upvotes: 2