5.2. Abstract Protocol
Protocol describe an interface, not an implementation
Protocol classes should not have method implementations
Protocol can describe both methods and attributes
Since Python 3.8
PEP 544 -- Protocols: Structural subtyping (static duck typing)
5.2.1. Problem
>>> class User:
... def login(self): ...
... def logout(self): ...
>>>
>>> class Admin:
... def log_in(self): ...
... def log_out(self): ...
>>> def auth(account):
... account.login()
... account.logout()
>>> mark = User()
>>> auth(mark) # type checker: ok
>>> mark = Admin()
>>> auth(mark) # type checker: error
Traceback (most recent call last):
AttributeError: 'Admin' object has no attribute 'login'. Did you mean: 'log_in'?
5.2.2. Solution: Abstract Class
Implementation is through the inheritance
Validation is dynamic (in the runtime)
>>> from abc import ABC, abstractmethod
>>>
>>>
>>> class Account(ABC):
... @abstractmethod
... def login(self): raise NotImplementedError
...
... @abstractmethod
... def logout(self): raise NotImplementedError
>>> class User(Account):
... def login(self): ...
... def logout(self): ...
>>>
>>> class Admin(Account):
... def login(self): ...
... def logout(self): ...
>>> def auth(account: Account):
... account.login()
... account.logout()
... print('done')
>>> mark = User()
>>> auth(mark) # type checker: ok
done
>>> mark = Admin()
>>> auth(mark) # type checker: ok
done
5.2.3. Solution: Protocol
Implementation is through structural subtyping
No inheritance, just complying to the structure (having the same methods)
>>> from typing import Protocol
>>>
>>>
>>> class Account(Protocol):
... def login(self): ...
... def logout(self): ...
>>>
>>>
>>> class User:
... def login(self): ...
... def logout(self): ...
>>>
>>> class Admin:
... def login(self): ...
... def logout(self): ...
>>> def auth(account: Account):
... account.login()
... account.logout()
... print('done')
>>> mark = User()
>>> auth(mark)
done
>>> mark = Admin()
>>> auth(mark)
done
5.2.4. Duck Typing
If it walks like a duck, and it quacks like a duck, then it must be a duck.
If it logins like an Account, and it logouts like an Account, then it must be an Account.
If it have method login like an Account, and it have method logout like an Account, then it adheres to the protocol Account.
If it walks like a duck, and it quacks like a duck, then it must be a duck.
If it logins like an Account, and it logouts like an Account, then it must be an Account.
If it have method login like an Account, and it have method logout like an Account, then it adheres to the protocol Account.
>>> class Account(Protocol):
... def login(self): ...
... def logout(self): ...
>>>
>>> class User:
... def login(self): ...
... def logout(self): ...
... def hello(self): ...
... def goodbye(self): ...
>>>
>>> class Admin:
... def login(self): ...
... def add_user(self): ...
... def del_user(self): ...
>>> def auth(account: Account):
... account.login()
... account.logout()
... print('done')
>>> mark = User()
>>> auth(mark) # ok
done
>>> mark = Admin()
>>> auth(mark) # error (no method logout)
Traceback (most recent call last):
AttributeError: 'Admin' object has no attribute 'logout'
5.2.5. Case Study
You must always remember to close a file. Note, that the following implementation is erroneous:
>>> file = open('/tmp/myfile.txt', mode='wt')
>>> file.write('hello')
5
>>> file.close()
What if there is no space left on a disk? Write will raise an exception. When there is an exception, the thread exits. If that was the only thread, then the program exits and file is freed. However in a multithreaded environment it will result in deadlock. It means, that the file was not closed, and no other thread can write file (as it is blocked):
>>> file = open('/tmp/myfile.txt', mode='wt')
>>> file.write('hello') # raises an exception: no space left on a disk
5
>>> file.close() # will not execute
Context Manager solves this problem. Code inside of a context manager block will execute and if there is an error, the file will be closed anyway:
>>> with open('/tmp/myfile.txt', mode='wt') as file:
... file.write('hello')
...
5
The following is the equivalent code. Context Manager has two methods __enter__() - called upon entering the context manager block (it should return self) and __exit__() - called upon exiting the context manager block. Method __exit__() is called even if there is an exception. In such case it information about an exception is passed as an argument:
>>> file = open('/tmp/myfile.txt', mode='wt').__enter__()
>>> try:
... file.write('hello')
... except Exception as err:
... file.__exit__(err.__class__, err, err.__traceback__)
... else:
... file.__exit__()
...
5
Knowing all the information about Context Manager let's try to add this feature to our class:
>>> class User:
... def hello(self):
... print('hello')
>>>
>>>
>>> with User() as mark:
... mark.hello()
...
Traceback (most recent call last):
TypeError: 'User' object does not support the context manager protocol
User
adheres to the Context Manager protocol, because
structurally covers all the methods required (it has __enter__()
and __exit__()
method. Hence it can be used in the with
statement.
Note, that there is no inheritance. User
does not specify that it
implements the protocol, it just simply does if there are proper methods.
There is no abstract class nor interface here. All those rules are based
on a fact, that your class has methods listed in the protocol:
>>> class User:
... def hello(self):
... print('hello')
...
... def __enter__(self):
... print('enter')
... return self
...
... def __exit__(self, exc_type, exc_val, exc_tb):
... print('exit')
>>>
>>>
>>> with User() as mark:
... mark.hello()
...
enter
hello
exit
User
adheres to the Context Manager protocol, because
structurally covers all the methods required.
>>>
... class ContextManager(Protocol):
... def __enter__(self): ...
... def __exit__(self, exc_type, exc_val, exc_tb): ...
...
... with <ContextManager> as <identifier>:
... ...
5.2.6. Terminology
PEP 544 propose to use the term protocols for types supporting structural subtyping. The reason is that the term iterator protocol, for example, is widely understood in the community, and coming up with a new term for this concept in a statically typed context would just create confusion [1].
This has the drawback that the term protocol becomes overloaded with two subtly different meanings: the first is the traditional, well-known but slightly fuzzy concept of protocols such as iterator; the second is the more explicitly defined concept of protocols in statically typed code. The distinction is not important most of the time, and in other cases we propose to just add a qualifier such as protocol classes when referring to the static type concept. [1]
If a class includes a protocol in its MRO, the class is called an explicit subclass of the protocol.
If a class is a structural subtype of a protocol, it is said to implement the protocol and to be compatible with a protocol. If a class is compatible with a protocol but the protocol is not included in the MRO, the class is an implicit subtype of the protocol. (Note that one can explicitly subclass a protocol and still not implement it if a protocol attribute is set to None in the subclass, see Python data-model for details.) [1]
The attributes (variables and methods) of a protocol that are mandatory for other class in order to be considered a structural subtype are called protocol members. [1]
5.2.7. Explicit Subtyping
Email
is explicit subclass of the protocol
If a class includes a protocol in its MRO, the class is called an explicit subclass of the protocol.
>>> class Account(Protocol):
... def login(self): ...
... def logout(self): ...
>>>
>>> class User(Account):
... def login(self): ...
... def logout(self): ...
5.2.8. Structural Subtyping
If an object that has all the protocol attributes it implements it
Structural subtyping is natural for Python programmers
Matches the runtime semantics of duck typing
User
is structural subtype of a protocol (it conforms to structure)User
is implicit subtype of the protocolAccount
(does not inherits)User
implement the protocolAccount
User
is compatible with a protocolAccount
If a class is a structural subtype of a protocol, it is said to implement the protocol and to be compatible with a protocol. If a class is compatible with a protocol but the protocol is not included in the MRO, the class is an implicit subtype of the protocol. (Note that one can explicitly subclass a protocol and still not implement it if a protocol attribute is set to None in the subclass, see Python data-model for details.) [1]
>>> class Account(Protocol):
... def login(self): ...
... def logout(self): ...
>>>
>>> class User:
... def login(self): ...
... def logout(self): ...
5.2.9. What Protocols are Not?
At runtime, protocol classes is simple ABC
No runtime type check
Protocols are completely optional
At runtime, protocol classes will be simple ABCs. There is no intent to provide sophisticated runtime instance and class checks against protocol classes. This would be difficult and error-prone and will contradict the logic of PEP 484. As well, following PEP 484 and PEP 526 Python steering committee states that protocols are completely optional [1]:
No runtime semantics will be imposed for variables or parameters annotated with a protocol class.
Any checks will be performed only by third-party type checkers and other tools.
Programmers are free to not use them even if they use type annotations.
There is no intent to make protocols non-optional in the future.
5.2.10. Runtime Checkable
By default
isinstance()
andissubclass()
won't work with protocolsYou can use
typing.runtime_checkable
decorator to make it work
The default semantics is that isinstance()
and issubclass()
fail for
protocol types. This is in the spirit of duck typing -- protocols basically
would be used to model duck typing statically, not explicitly at runtime.
However, it should be possible for protocol types to implement custom
instance and class checks when this makes sense, similar to how Iterable
and other ABCs in collections.abc
and typing
already do it, but this
is limited to non-generic and unsubscripted generic protocols (Iterable
is statically equivalent to Iterable[Any]
).
>>> class Account(Protocol):
... def login(self): ...
... def logout(self): ...
>>>
>>> class User:
... def login(self): ...
... def logout(self): ...
>>>
>>>
>>> isinstance(User, Account)
Traceback (most recent call last):
TypeError: Instance and class checks can only be used with @runtime_checkable protocols
The typing module will define a special @runtime_checkable
class
decorator that provides the same semantics for class and instance checks
as for collections.abc
classes, essentially making them 'runtime
protocols':
>>> from typing import Protocol, runtime_checkable
>>>
>>>
>>> @runtime_checkable
... class Account(Protocol):
... def login(self): ...
... def logout(self): ...
>>>
>>> class User:
... def login(self): ...
... def logout(self): ...
>>>
>>>
>>> isinstance(User, Account)
True
5.2.11. Unions
>>> class CanLogin(Protocol):
... def login(self): ...
>>>
>>> class CanLogout(Protocol):
... def logout(self): ...
>>> def auth(account: CanLogin | CanLogout) -> None:
... account.login()
... account.logout()
5.2.12. Merging Protocols
>>> class CanLogin(Protocol):
... def login(self): ...
>>>
>>> class CanLogout(Protocol):
... def logout(self): ...
>>> class Account(CanLogin, CanLogout, Protocol):
... ...
5.2.13. Interface Segregation Principle
S.O.L.I.D. Principles
ISP - Interface Segregation Principle
Clients should not be forced to depend upon interfaces that they do not use.
Clients should not be forced to depend upon interfaces that they do not use.
—SOLID, Interface Segregation Principle, Robert C. Martin
Instead of:
>>> class Account(Protocol):
... def login(self): ...
... def logout(self): ...
It is better to have:
>>> class CanLogin(Protocol):
... def login(self): ...
>>>
>>>
>>> class CanLogout(Protocol):
... def logout(self): ...
5.2.14. Generic Protocols
>>> from abc import abstractmethod
>>> from typing import Protocol, TypeVar, Iterator
>>>
>>>
>>> T = TypeVar('T')
>>>
>>> class Iterable(Protocol[T]):
... @abstractmethod
... def __iter__(self) -> Iterator[T]:
... ...
5.2.15. Recursive Protocols
Since 3.11 PEP 673 –- Self Type
>>> from typing import Protocol, Iterable, Self
Traversing Graph nodes:
>>> class Graph(Protocol):
... def get_node(self) -> Iterable[Self]:
... ...
Traversing Tree nodes:
>>> class Tree(Protocol):
... def get_node(self) -> Iterable[Self]:
... ...
5.2.16. Modules as Implementations of Protocols
A module object is accepted where a protocol is expected if the public interface of the given module is compatible with the expected protocol. For example:
File myapp/config.py
:
>>> database_host = '127.0.0.1'
>>> database_port = 5432
>>> database_name = 'users'
>>> database_user = 'mwatney'
>>> database_pass = 'Ares3'
File myapp/main.py
:
>>> from typing import Protocol, runtime_checkable
>>>
>>>
>>> @runtime_checkable
... class DatabaseConfig(Protocol):
... database_host: str
... database_port: int
... database_name: str
... database_user: str
... database_pass: str
>>>
>>>
>>> import myapp.config
>>> isinstance(myapp.config, DatabaseConfig) # type checker: ok
5.2.17. Use Case - 1
>>> from typing import Protocol
>>>
>>>
>>> class SupportsClose(Protocol):
... def close(self) -> None:
... ...
5.2.18. Use Case - 2
>>> from dataclasses import dataclass
>>> class SupportsWrite(Protocol):
... def write(self): ...
>>>
>>>
>>> def print(*values, sep=' ', end='\n', file: SupportsWrite = None):
... ...
>>> @dataclass
... class File:
... filename: str
...
... def write(self):
... ...
>>>
>>> print('hello', 'world', file=File('/tmp/myfile.txt'))
5.2.19. Use Case - 2
File myapp/users.py
>>> PATH = '/api/v1/users/'
>>>
>>>
>>> def get(request):
... ...
>>>
>>> def post(request):
... ...
>>>
>>> def put(request):
... ...
>>>
>>> def delete(request):
... ...
File myapp/main.py
>>> from typing import Protocol
>>> import myapp.users
>>>
>>>
>>> class Endpoint(Protocol):
... PATH: str
... def get(request): ...
... def post(request): ...
... def put(request): ...
... def delete(request): ...
>>>
>>> def router_register(endpoint: Endpoint):
... ...
>>>
>>> router_register(myapp.users) # ok
5.2.20. Use Case - 3
File myapp/test.py
:
>>> import logging
>>>
>>> URL = 'https://python3.info/index.html'
>>>
>>> def on_success(html: str) -> None:
... assert '...' in html
>>>
>>> def on_error(error: Exception) -> None:
... logging.error(error)
File myapp/main.py
:
>>> from typing import Protocol
>>> import myapp.tests
>>>
>>>
>>> class Test(Protocol):
... URL: str
... def on_success(self, html: str) -> None: ...
... def on_error(self, error: Exception) -> None: ...
>>>
>>> def run(test: Test):
... ...
>>>
>>> run(myapp.tests) # ok
5.2.21. Use Case - 3
>>> from abc import abstractmethod
>>> from typing import Protocol
>>>
>>>
>>> class RGB(Protocol):
... rgb: tuple[int, int, int]
...
... @abstractmethod
... def opacity(self) -> int:
... return 0
>>>
>>>
>>> class Pixel(RGB):
... def __init__(self, red: int, green: int, blue: float) -> None:
... self.rgb = red, green, blue
...
Type checker will warn:
blue
must beint
opacity
is not defined
5.2.22. Use Case - 4
This use case demonstrate how mypy
display information about invalid
method. Cache
protocol defines .clear()
method which is not present
in the DatabaseCache
implementation. mypy
will raise and error and
fail CI/CD build.
>>> class Cache(Protocol):
... def set(self, key: str, value: str) -> None: ...
... def get(self, key: str) -> str: ...
... def clear(self) -> None: ...
>>> class DatabaseCache:
... def set(self, key: str, value: str) -> None:
... ...
...
... def get(self, key: str) -> str:
... return '...'
>>>
>>>
>>> mycache: Cache = DatabaseCache()
>>> mycache.set('firstname', 'Mark')
>>> mycache.set('lastname', 'Watney')
>>> fname = mycache.get('firstname')
>>> lname = mycache.get('lastname')
$ python3 -m mypy myfile.py
myfile.py:16: error: Incompatible types in assignment (expression has type "DatabaseCache", variable has type "Cache") [assignment]
myfile.py:16: note: "DatabaseCache" is missing following "Cache" protocol member:
myfile.py:16: note: clear
Found 1 error in 1 file (checked 1 source file)
5.2.23. Use Case - 5
This use case demonstrate how mypy
display information about invalid
argument type to the .set()
method. In Cache
protocol .set()
method has value: str
. In the implementation .set()
method has
value: int
. Despite this is only type annotation change, mypy
will raise an error and fail CI/CD build.
>>> class Cache(Protocol):
... def set(self, key: str, value: str) -> None: ...
... def get(self, key: str) -> str: ...
... def clear(self) -> None: ...
>>> class DatabaseCache:
... def set(self, key: str, value: int) -> None:
... ...
...
... def get(self, key: str) -> str:
... return '...'
...
... def clear(self) -> None:
... ...
>>>
>>>
>>> mycache: Cache = DatabaseCache()
>>> mycache.set('firstname', 'Mark')
>>> mycache.set('lastname', 'Watney')
>>> fname = mycache.get('firstname')
>>> lname = mycache.get('lastname')
>>> mycache.clear()
$ python -m mypy myfile.py
myfile.py:25: error: Incompatible types in assignment (expression has type "DatabaseCache", variable has type "Cache") [assignment]
myfile.py:25: note: Following member(s) of "DatabaseCache" have conflicts:
myfile.py:25: note: Expected:
myfile.py:25: note: def set(self, key: str, value: str) -> None
myfile.py:25: note: Got:
myfile.py:25: note: def set(self, key: str, value: int) -> None
Found 1 error in 1 file (checked 1 source file)
5.2.24. Use Case - 6
This time example is valid and does not contain any errors. This will allow for success build in CI/CD system.
>>> class Cache(Protocol):
... def set(self, key: str, value: str) -> None: ...
... def get(self, key: str) -> str: ...
... def clear(self) -> None: ...
>>> class DatabaseCache:
... def set(self, key: str, value: str) -> None:
... ...
...
... def get(self, key: str) -> str:
... return '...'
...
... def clear(self) -> None:
... ...
>>>
>>>
>>> mycache: Cache = DatabaseCache()
>>> mycache.set('firstname', 'Mark')
>>> mycache.set('lastname', 'Watney')
>>> fname = mycache.get('firstname')
>>> lname = mycache.get('lastname')
>>> mycache.clear()
$ python -m mypy myfile.py
Success: no issues found in 1 source file