You can use a Protocol with a property that does not have a setter:
class Identifiable(Protocol): @property def id(self) -> int: ...
Now object.id = 2 fails with a readable mypy error: asd.py:29: error: Property "id" defined in "Identifiable" is read-only
As a side note, it's might not be a good idea to make a variable named id or object, as those are names of built-ins, and it's not clear whether object somewhere in the middle of the code refers to object the built-in class or some variable named object. This is worst with global variables, but I don't consider it good practice with local variables either.
Videos
In general, declare the Protocol using a read-only property, not a read/write field:
class P(Protocol):
@property
def v(self) -> int:
pass
This is needed because a read-only protocol attribute is satisfied by both a read-only property and a read/write field. In contrast, a read/write protocol attribute is satisfied only by a read/write field, not a read-only property.
As PyRight insists that fields and properties are different kinds of attributes, the attribute must be declared with both variants – once as a field and once as an attribute. For simple protocols, this can be done by declaring a separate field and property variant of the property:
# field only
class Pf(Protocol):
v: int
# property only
class Pp(Protocol):
@property
def v(self) -> int:
return 1
# Either field or property
P = Union[Pf, Pp]
This is valid for both MyPy and PyRight.
If you are not strict about the type annotated for the property's getter, you could define P as:
from typing import Protocol, Union, ClassVar
class P(Protocol):
v: Union[int, ClassVar]
This is exactly what Protocols are for. In short, Protocols let you use structural instead of nominal subtyping. With nominal subtyping, type A is a subtype of B if A explicitly inherits or extends B. With structural subtyping, type A is a subtype of B if it has the same method and attribute "signatures" as B (with some restrictions).
For example:
# If you're using Python 3.8+
from typing import Protocol
# If you need to support older versions of Python,
# pip-install the 'typing_extensions' module and do:
from typing_extensions import Protocol
class SupportsBarBaz(Protocol):
bar: int
baz: int
class MyUnrelatedClass1:
def __init__(self, bar: int, baz: int) -> None:
self.bar = bar
self.baz = baz
class MyUnrelatedClass2:
def __init__(self, bar: int, baz: int, blah: str) -> None:
self.bar = bar
self.baz = baz
self.blah = blah
class MyUnrelatedClass3:
def __init__(self, bar: str, baz: str, blah: str) -> None:
self.bar = bar
self.baz = baz
self.blah = blah
def foo(a: SupportsBarBaz) -> int:
return a.bar + a.baz
# These both type-check, even though there's no explicit relationship
# between 'SupportsBarBaz' and these two classes
foo(MyUnrelatedClass1(1, 2))
foo(MyUnrelatedClass2(1, 2, "abc"))
# But this doesn't type-check, since 'bar' and 'baz' are both strs here
foo(MyUnrelatedClass3("a", "b", "c"))
You can find more information about using Protocols in the mypy docs. The information in that page is all compliant with the PEP, so the info there should all apply to other type checkers, assuming they've finished implementing their own support for Protocols.
You can also find slightly more complex examples of using Protocols in typeshed, the repository of type hints for the Python standard library.
Though, I suppose this all matters only if you actually intend on using static analysis in your code. If not, you could maybe do something simpler and just define a custom type alias to Any, document what that alias is "supposed" to mean, and use that alias instead of a full-fledged protocol. That alias would be almost completely useless for the purposes of static analysis/autocompletion tools/etc, but humans generally have no issues reading comments.
Type hints can only refer to a class, so create an abstract class
import abc
class MyType(abc.ABC):
@abc.abstractproperty
def foo(self):
pass
@abc.abstractproperty
def bar(self):
pass
And declare f(a: MyType)