Python Protocols

August 3, 2019 (5y ago)

Archive

Structural Subtyping

PEP 544 introduces the notion of structural subtyping or static duck typing to Python. Here's how it works:

1from typing import Protocol
2
3class OrganicOrganism(Protocol):
4
5    def grows(self) -> None: ...
6
7    def reproduces(self) -> None: ...
8
9    def responds_to_stimuli(self) -> None: ...
10
11    def adapts(self) -> None: ...
12
13
14class Human:
15
16    def grows(self) -> None: pass
17
18    def reproduces(self) -> None: pass
19
20    def responds_to_stimuli(self) -> None: pass
21
22    def adapts(self) -> None: pass
23
24    def conscious(self) -> None: pass
25
26
27H: OrganicOrganism = Human()
28O: Human = OrganicOrganism() 
29

Type checking this with MyPy shows complaints

(.venv) ➜ mypy main.py 
main.py:26: error: Cannot instantiate protocol class "OrganicOrganism"  [misc]
main.py:26: error: Incompatible types in assignment (expression has type "OrganicOrganism", variable has type "Human")  [assignment]
Found 2 errors in 1 file (checked 1 source file)

But when you fix it

H: OrganicOrganism = Human()
# O: Human = OrganicOrganism() 
# no every organism is conscious

You get

(.venv) ➜ mypy main.py 
Success: no issues found in 1 source file

Every human behaves like an organism in some way, but an organism may not behave like a human

How about a plant

class Plant:

    def grows(self) -> None: pass

    def reproduces(self) -> None: pass

    def responds_to_stimuli(self) -> None: pass

    def adapts(self) -> None: pass

P: OrganicOrganism = Plant() #Ok

Even though Human and Plant don't share a common ancestor, they both conform to the OrganicOrganism protocol because they exhibit the required methods. No need for inheritance here.

Ok we know all that, what's special

Let's say you have various classes representing different request or response objects, possibly from different libraries. You aim to consolidate these diverse objects and their methods into a unified class or adapter. You might expect these classes to inherit from a common base class. But, by using protocols, you can define a common interface that outlines the expected behavior of response objects with an abstracted one. This enables disparate classes to conform to the same structure without requiring a shared ancestry.

from typing import Optional, Dict, Protocol, runtime_checkable


@runtime_checkable
class RequestProtocol(Protocol):
    def p_cookie(self, cookie_name: str) -> Optional[str]:
        ...
    @property
    def p_all_cookies(self) -> Dict[str, str]:
        ...
    @property
    def p_is_connection_secure(self) -> bool:
        ...

    @property
    def p_base_url(self) -> str:
        ...

You can extend these different Request objects to where they adhere to the protocol.

from libx import Request

class RequestX(Request):
    def p_cookie(self, cookie_name: str) -> Optional[str]:
        return self.cookies.get(cookie_name)

    @property
    def p_cookies(self) -> Dict[str, str]:
        return self.cookies

    @property
    def p_is_connection_secure(self) -> bool:
        return self.url.is_secure

    @property
    def p_base_url(self) -> str:
        return str(self.base_url)
from liby import Request

class RequestY(Request):
    def p_cookie(self, cookie_name: str) -> Optional[str]:
        return self.cookie(cookie_name)

    @property
    def p_all_cookies(self) -> Dict[str, str]:
        return self.cookies

    @property
    def p_is_connection_secure(self) -> bool:
        return self.is_https

    @property
    def p_base_url(self) -> str:
        return self.base_url

As you can see these different libraries have different implementation over how they access different request properties/methods, but with a protocol you can define a unified way that all these different objects adhere to, now for example if you want to create a function that processes a given request you can annotate the request parameter with RequestProtocol, this means that his function will process any class that adheres to this protocol.

def process_request(req: RequestProtocol) -> None:

    # do anything with the shared methods
    process_request(req=RequestX(...)) # OK, no type issues 
    process_request(req=RequestX(...)) # OK, no type issues

Another use case is using Protocol to type function signatures.

class Foo:
    ...

@runtime_checkable
class Bar(Protocol):
    async def __call__(self, foo: Foo) -> None:
        ...

Any function that is asynchronous, accepts an argument called foo of type Foo and returns nothing, adheres to this protocol, a given function might be.

async def bar(foo: Foo): pass

If you would have typed this function otherwise it would have been.

f: Callable[[Foo],Awaitable[None]] = bar

It's much easier to type functions using Protocol instead of:

def f(bar: Callable[[Foo],Awaitable[None]]) -> Any: ...

If you annotate the protocol with @runtime_checkable, you're allowed access to isinstance() and issubclass() functions to check the type at runtime.

def f(bar: Bar) -> Any:
	if not isinstance(bar,Bar): ... # do something 

You can also create a function that checks the signature of another function, if it adheres to the protocol or not, and provide helpful error messages over why the given object does not adhere to the protocol.

import inspect 

def check_signature(f: Bar) -> None:
    sig = inspect.signature(f)
    if not (
        "foo" in sig.parameters
        and sig.parameters["foo"].annotation == Foo
    ):
        raise TypeError(
            f"Given function does not adhere to Bar protocol: {f}"
        )