Optional strict mode for @runtime_checkable

Idea

Extend @typing.runtime_checkable with optional parameters:

def runtime_checkable(*,
    values: bool = False,
    signatures: bool = False,
    return_annotations: bool | None = None,
)
  • values – check runtime types of attribute values in isinstance(obj, Proto) for data (non-callable) members,
  • signatures – check member function signatures (both parameters and return annotation) in isinstance(obj, Proto) and issubclass(Cls, Proto),
  • return_annotations – adjust checking of return annotation in member function signatures (same as signatures by default).

Motivation

According to PEP-544, calling isinstance() for @runtime_checkable protocol only checks for attribute presence. There are cases when deeper checks could be useful. For example, TypeIs guards based on protocols to validate and hint that some optional attributes are set, etc.

The current situation seems limited and confusing, e.g. for data protocols:

@runtime_checkable
class Foo(Protocol):
    bar: int

@dataclass
class Baz:
    bar: str
>>> isinstance(Baz('hi'), Foo)  # ??!
True

Similar discussion was raised before:

Example

class Resource1: ...
class Resource2: ...

@dataclass
class Operator:
    resource1: Resource1 | None = None
    resource2: Resource2 | None = None

    @runtime_checkable(values=True)
    class Ready(Protocol):
        resource1: Resource1  # required
        resource2: Resource2  # required

    def process(self) -> None:
        if not isinstance(self, self.Ready):
            raise RuntimeError()
        
        # we checked that resource1 and resource2 are not None
        # and type checker knows that too

        # and we didn't have to write
        #     if not (
        #         isinstance(self.resource1, Resource1)
        #         and isinstance(self.resource2, Resource2)
        #     ):
        #         raise RuntimeError()

        # ... code using resource1 and resource2

Any thoughts?

My view of Protocols is that runtime checkable is not a feature I would use often. A static type checker deals makes them unnecessary, and the runtime type checking libraries can choose to implement the behaviour they feel necessary (i.e. I don’t think they are likely to use isinstance, they’d use their own function). So from that perspective I don’t think the suggested change is warranted. But I’m sure there’s something I don’t quite get since I never implement runtime checkable Protocols.

4 Likes

Do you think something like this could be implemented as one or more free functions in a third-party library?

1 Like

Unfortunately, I don’t see a way to implement alternative @strict_runtime_checkable without monkey-patching typing._ProtocolMeta, otherwise it would be a good way to go.

PEP 544 says:

To reiterate, providing complex runtime semantics for protocol classes is not a goal of this PEP, the main goal is to provide a support and standards for static structural subtyping. The possibility to use protocols in the runtime context as ABCs is rather a minor bonus that exists mostly to provide a seamless transition for projects that already use ABCs.

In other words runtime_checkable exists only because ABCs provided this limited runtime type checking before and Protocols are in many ways the new ABCs.

I would say that doing runtime checks with Protocols is a bad idea and is not what Protocols should be used for. It is quite clear that nothing at runtime can actually verify static types properly even in simple cases e.g. what is the type of an empty list?

def append(b: list[int] | list[str]):
    # There is no possible way to check this:
    if isinstance(b, list[int]):
         b.append(1)
    else:
         b.append("1")

a: list[str] = []

append(a)

If the types of primitive objects cannot be checked then the types of Protocols cannot be checked either.

The authors of PEP 544 were well aware of the fact that general static types cannot be used for runtime checks which is why no attempt was made to support that apart from mimicking what was already done with ABCs.

For your code demonstration there are many alternative ways to do this that don’t need protocols e.g. here is one possibility:

@dataclass
class Operator:
    resource1: Resource1 | None = None
    resource2: Resource2 | None = None

    def get_ready(self) -> tuple[Resource1, Resource2]:
       if self.resource1 is None or self.resource2 is None:
           raise RuntimeError
       else:
           return (self.resource1, self.resource2)
5 Likes

@oscarbenjamin thank you for the exhaustive explanation! The situation is obvious now, it’s not possible to extend @runtime_checkable this way.

@Tinche thank you for the hint, it is probably possible to have e.g. DataProtocol that will allow instance-checking member data types at runtime for trivial cases like non-generic union types and literals (and emit user warnings for more complex cases). This would be enough for some practical cases and could be packaged.

1 Like