#!/usr/bin/env python3
from abc import ABCMeta, abstractmethod
class Base(metaclass = ABCMeta):
@classmethod
def __subclasshook__(cls, subclass):
return (
hasattr(subclass, 'x')
)
@property
@abstractmethod
def x(self) -> float:
raise NotImplementedError
class Concrete:
x: float = 1.0
class Application:
def __init__(self, obj: Base) -> None:
print(obj.x)
ob = Concrete()
app = Application(ob)
print(issubclass(Concrete, Base))
print(isinstance(Concrete, Base))
print(type(ob))
print(Concrete.__mro__)
python test_typing.py
returns:
1.0
True
False
<class '__main__.Concrete'>
(<class '__main__.Concrete'>, <class 'object'>)
and mypy test_typing.py
returns:
test_typing.py:30: error: Argument 1 to "Application" has incompatible type "Concrete"; expected "Base"
Found 1 error in 1 file (checked 1 source
But if i change the line class Concrete:
to class Concrete(Base):
, i get for
python test_typing.py
this:
1.0
True
False
<class '__main__.Concrete'>
(<class '__main__.Concrete'>, <class '__main__.Base'>, <class 'object'>)
and for mypy test_typing.py
this:
Success: no issues found in 1 source file
If i add to my code this:
reveal_type(Concrete)
reveal_type(Base)
i get in both cases the same results for it from mypy test_typing.py
:
test_typing.py:37: note: Revealed type is "def () -> vmc.test_typing.Concrete"
test_typing.py:38: note: Revealed type is "def () -> vmc.test_typing.Base"
Seems obvious, that MyPi have some problems with virtual base classes but non-virtual inheritance seems working as expected.
How works MyPy's type estimation in these cases? Is there an workaround?
Using Protocol
pattern:
#!/usr/bin/env python3
from abc import abstractmethod
from typing import Protocol, runtime_checkable
@runtime_checkable
class Base(Protocol):
@property
def x(self) -> float:
raise NotImplementedError
@abstractmethod
def __init__(self, x: float) -> None:
raise NotImplementedError
"""
@classmethod
def test(self) -> None:
pass
"""
class Concrete:
x: float = 1.0
class Application:
def __init__(self, obj: Base) -> None:
pass
ob = Concrete()
app = Application(ob)
mypy
: Success: no issues found in 1 source file
isinstance(Concrete, Base)
: True
issubclass(Concrete, Base)
: TypeError: Protocols with non-method members don't support issubclass()
__init__
method signatures: __init__(self, x: float) -> None
vs. __init__(self) -> None
(Why returns inspect.signature()
the strings (self, *args, **kwargs)
and (self, /, *args, **kwargs)
here? With class Base:
instead of class Base(Protocol):
i get (self, x: float) -> None
and (self, /, *args, **kwargs)
)@abstractmethod
and @classmethod
(treats ANY method as abstract)This time just an more complex example of the 1st code:
#!/usr/bin/env python3
from abc import ABCMeta, abstractmethod
from inspect import signature
class Base(metaclass = ABCMeta):
@classmethod
def __subclasshook__(cls, subclass):
return (
hasattr(subclass, 'x') and
(signature(subclass.__init__) == signature(cls.__init__))
)
@property
@abstractmethod
def x(self) -> float:
raise NotImplementedError
@abstractmethod
def __init__(self, x: float) -> None:
raise NotImplementedError
@classmethod
def test(self) -> None:
pass
class Concrete:
x: float = 1.0
def __init__(self, x: float) -> None:
pass
class Application:
def __init__(self, obj: Base) -> None:
pass
ob = Concrete(1.0)
app = Application(ob)
issubclass(Concrete, Base)
: True
isinstance(Concrete, Base)
: False
__init__
.test_typing.py:42: error: Argument 1 to "Application" has incompatible type "Concrete"; expected "Base"
Found 1 error in 1 file (checked 1 source file)
In some circumstances the following code might be an possible solution.
#!/usr/bin/env python3
from typing import Protocol, runtime_checkable
from dataclasses import dataclass
@runtime_checkable
class Rotation(Protocol):
@property
def x(self) -> float:
raise NotImplementedError
@property
def y(self) -> float:
raise NotImplementedError
@property
def z(self) -> float:
raise NotImplementedError
@property
def w(self) -> float:
raise NotImplementedError
@dataclass
class Quaternion:
x: float = 0.0
y: float = 0.0
z: float = 0.0
w: float = 1.0
def conjugate(self) -> 'Quaternion':
return type(self)(
x = -self.x,
y = -self.y,
z = -self.z,
w = self.w
)
class Application:
def __init__(self, rot: Rotation) -> None:
print(rot)
q = Quaternion(0.7, 0.0, 0.7, 0.0)
app = Application(q.conjugate())
__init__
method because of @dataclass
usage. here: (self, x: float = 0.0, y: float = 0.0, z: float = 0.0, w: float = 1.0) -> None
isinstance()
: True
Success: no issues found in 1 source file
@dataclass
along with implementing your interface..__init__
methods, that are not only taken class attributes.Tipp: If an forced __init__
method is not required and only want to take care of the attributes, then just omit @dataclass
.
Updated the 4th code to provide more safety, but without implicit __init__
method:
#!/usr/bin/env python3
from abc import abstractmethod
from typing import Protocol, runtime_checkable
@runtime_checkable
class Rotation(Protocol):
@property
@abstractmethod
def x(self) -> float:
raise NotImplementedError
@property
@abstractmethod
def y(self) -> float:
raise NotImplementedError
@property
@abstractmethod
def z(self) -> float:
raise NotImplementedError
@property
@abstractmethod
def w(self) -> float:
raise NotImplementedError
class Quaternion:
_x: float = 0.0
_y: float = 0.0
_z: float = 0.0
_w: float = 1.0
@property
def x(self) -> float:
return self._x
@property
def y(self) -> float:
return self._y
@property
def z(self) -> float:
return self._z
@property
def w(self) -> float:
return self._w
def __init__(self, x: float, y: float, z: float, w: float) -> None:
self._x = float(x)
self._y = float(y)
self._z = float(z)
self._w = float(w)
def conjugate(self) -> 'Quaternion':
return type(self)(
x = -self.x,
y = -self.y,
z = -self.z,
w = self.w
)
def __str__(self) -> str:
return ", ".join(
(
str(self._x),
str(self._y),
str(self._z),
str(self._w)
)
)
def __repr__(self) -> str:
cls = self.__class__
module = cls.__module__
return f"{module + '.' if module != '__main__' else ''}{cls.__qualname__}({str(self)})"
class Application:
def __init__(self, rot: Rotation) -> None:
print(rot)
q = Quaternion(0.7, 0.0, 0.7, 0.0)
app = Application(q.conjugate())
The Protocol
way is unstable.
But the Metaclass
way is not checkable, because it's not working with MyPy (because it's not static).
Are there any alternative solutions to achieve some type of Interfaces (without class Concrete(Base)
) AND make it type-safe (checkable)?
After running some tests and more research i am sure, that the actual problem is the behaviour of Protocol
to silently overwrite the defined __init__
method.
Seems logical, since Protocols are not intended to be initiated.
But sometimes it's required to define an __init__
method,
because in my opinion __init__
methods are also part of the interface of classes and it's objects.
I found an existing issue about this problem, that seems to confirm my point of view: https://github.com/python/cpython/issues/88970
Fortunately it's already fixed: https://github.com/python/cpython/commit/5f2abae61ec69264b835dcabe2cdabe57b9a990e
But unfortunately, this fix will only be part of Python 3.11 and above.
Currenty is Python 3.10.5 available.
WARNING: Like mentioned in the issue, some static type checkers might behave different in this case. MyPy just ignores the missing __init__
method (tested it, confirmed) BUT Pyright seems to detect and report the missing __init__
method (not tested by me).