AbstractType (take 2) or allowing TypeForm to be used in isinstance

TL;DR

  • type[P] can’t be used with non-concrete types such as Protocol or ABC
  • there’s a divergent behavior between different static type checkers
  • as is, the typing specification favors mypy behavior in popping an error from this use case
  • it is a useful feature to have in checking at runtime if an object implements an interface of a type, to enstablish a contract
  • to allow type checkers (static and runtime) to converge on an unique understanding of the concept, which is used quite a lot, a solution could be found in using TypeForm, a custom AbstractType, or reviewing the typing specification on this point (which I think will never happen concretely, but I may be wrong)

This is a revival of the topic originally discussed in this thread.

The problem

Consider the following code snippet:

from typing import Protocol, TypeVar

class BaseProtocol(Protocol):
    def method(self) -> str: ...

class MyProtocol(BaseProtocol, Protocol):
    def other_method(self) -> int: ...

class MyClass(BaseProtocol):
    def method(self) -> str:
        return "Hello"
    
class MyOtherClass(BaseProtocol):
    def method(self) -> str:
        return "World"
    
    def other_method(self) -> int:
        return 42

T = TypeVar('T', bound=BaseProtocol)

def filter_objects(objects: list[BaseProtocol], obj_type: type[T]) -> list[T]:
    return [obj for obj in objects if isinstance(obj, obj_type)]


objs: list[BaseProtocol] = [MyClass(), MyOtherClass()]

# up until now, no problems
# the next line is the problematic one
filtered = filter_objects(objs, MyProtocol)

The objective is to filter a group objects - all inheriting from the same type (in this case BaseProtocol, but it could also be a concrete type) and return a sub-group of objects that structurally match the given input, non-concrete type (a protocol in this case).

At runtime this works fine.

Type checking behavior

When running a type checker (for the scope of this thread I chose mypy, pyright and ty) there are two behaviors. I’m attaching links to playgrounds for each. Each playground has been set in strict mode (I couldn’t find an option for ty so maybe it is not…).

This has been a long standing issue in mypy that people have resolved in saying “the associate error code of mypy, type-abstract, should be removed entirely”. Overall the GitHub issue presents a various amount of reasons why using type[P] is ergonomic, for example this. Additionally, disabling the type-abstract error is not a solution for library developers, because as stated in this comment:

Disabling the error doesn’t help for library authors, because you have to tell all your downstream users to disable it as well

The thing is that according to the typing specification, mypy is right and the others are wrong. Or rephrasing, according to the specification, it looks to me like mypy adheres more strictly to it.

Possible solutions

PEP 747 introduced TypeForm, but as noted in this other thread from the author of PEP 747 himself:

isinstance only accepts a type[T] , not a TypeForm[T]

So, possible solutions that I could think of:

  • modify the behavior of isinstance to accept TypeForm when the inner type is a non concrete type
  • introduce an AbstractType that can be explicitly used for structural checks against isinstance
  • revisit the typing specification to allow type[T] to accept non-concrete types (this is an extreme solution that I don’t think anyone will want to follo)

This is primarely to allow different type checkers to converge on a more concrete idea on how to treat non-concrete types; I don’t pretend that the existing behavior for pyright and ty be changed, but in the long run it would allow situations where using type[ABC] can be covered more easily.

Any of the solutions would require a PEP I would say; never done it but first I wanted to gather some opinions since the topic has been silent for a bit.

Considerations on runtime type checkers

Some users pointed out that this use case would fall short at runtime because isinstance only checks the existance of attributes within the object that adhere to the protocol. While this is a fair point, at this time runtime type checkers have no other way of doing this. At the end of this comment I replied that both typeguard and beartype use precisely isinstance for protocol runtime type checking - so it’s basically a moot point.

As far as I managed to understand, a true, strict runtime checking of protocols can only be done via a custom metaclass that reimplements __instancecheck__. I think beartype does something similar by implementing a custom protocol class inheriting from _ProtocolMeta, but I might be off.

I’m not sure how that would work; there are many possible TypeForms that aren’t accepted by isinstance() at runtime.

Introducing yet another concept here would be confusing.

I’m actually sympathetic to this. I feel the most obvious meaning for type[] is that it includes any instance of type. If you want it to be instantiable, use Callable[] instead.

3 Likes

@Jelle thanks, I haven’t thought about the last point.

At runtime:

from typing import Protocol

class MyProtocol(Protocol):
    def method(self) -> str:
        ...


print(callable(MyProtocol)) # prints True

So some may consider it a bit risky.

I’m not sure how that would work; there are many possible TypeForms that aren’t accepted by isinstance() at runtime.

To be perfectly honest I still have no idea exactly how TypeForm is used in an actual runtime context. All I’ve seen so far in the PEP are stub implementation on how signatures and types would look like, but nothing concrete.

But even so, I think using TypeForm in this manner would mean accepting that if the inner type of TypeForm is an ABC or a Protocol, then it should be acceptable for isinstance to check against. As far as I can tell, it’s the solution that - intuitively - works for me, because the most common use case I’ve seen is to annotate type[P] with some form of the two.

I don’t think “inner type” is a meaningful concept. TypeForm[T] where T is an ABC or Protocol may include objects that aren’t runtime types, such as unions, potentially Literals, and Any.

I get your point. But I noticed swapping out Protocol for ABC fixes this.

I won’t claim there is never any use for this. Nor claim there’s never any need to test at runtime, for all the classes explicitly declared to inherit from a BaseProtocol subclass, out of all the classes the type checker structurally passed as valid implementations of that BaseProtocol. And it’s super useful to know ty and pyright allow this.

But it seems a little bit to me, like such extra code is only necessary if you don’t trust mypy. And then this request feels like asking the type system and mypy to implement a new feature, to type check extra code, which is only necessary if the rest of what mypy is doing is untrusted.

I’m not exactly sure I follow your reasoning.

What do you mean exactly with not trusting mypy? If anything, I should be trusting it more than pyright and ty - and even using the term “trust” feels wrong.

I’m simply stating the typing specification as is takes the assumption that at any point in time someone may use something like what described in the specs:

class Proto(Protocol):
    @abstractmethod
    def meth(self) -> int:
        ...
class Concrete:
    def meth(self) -> int:
        return 42

def fun(cls: type[Proto]) -> int:
    return cls().meth() # OK
fun(Proto)              # Error
fun(Concrete)           # OK

necessary implies that people will be implementing functions that will necessarely call for objects that are not instantiable. Which is a bit too conservative as shown in the miriad of examples posted in the various links.

But I noticed swapping out Protocol for ABC fixes this.

You mean replacing inheritance from Protocol with ABC?

I’m saying if Protcols and mypy are being used together as intended, to support structural sub typing, then I don’t understand why all such isinstance checks can’t be removed.

The type checker’s already ensured the object satisfies the contract you’ve defined with the Protocol. What use is it to know if it’s also an explicit sub class of that same Protocol or not?

In my opinion, the whole point of Protocols is that isinstance checks shouldn’t matter!

Where as if you want to enforce subclassing, that’s exactly what ABCs are for.

I know it’s common to subclass Protocols (not just to create other Protocols). But isn’t that just a readability boon (“explicit is better them implicit”). Being a subclass of a Protocol or not, should have nothing to do with the Protocol structural typing mechanism --by definition–, shouldn’t it?

1 Like

Ok, I’m guessing my snippet is misleading so I’ll rewrite one more tied to my use case.

The context is a library I’m working on for creating custom applications for scientific acquisitions sharing the same base infrastructure and relying on protocols to control specific devices behavior based on the API that these devices implement. The devices can be a mix and match of attributes and methods, and an upper controller layer is fed with the full map of available devices and - depending on the specific protocol - it should access only a subgroup of those devices.

The only root these devices have is the fact that there has to be a name property.

from abc import abstractmethod
from typing import Protocol, TypeVar, runtime_checkable

# protocols.py begin
@runtime_checkable
class Device(Protocol):

    @property
    @abstractmethod
    def name(self) -> str: ...

@runtime_checkable
class Settable(Device, Protocol):

    @abstractmethod
    def set(self, value: int) -> None: ...

@runtime_checkable
class Scannable(Device, Protocol):

    @abstractmethod
    def prepare(self, values: list[int]) -> None: ...

    @abstractmethod
    def scan(self) -> None: ...
# protocols.py end

# devices.py begin
class Motor:

    def __init__(self, name: str) -> None:
        self._name = name
        self._position = 0
    
    @property
    def name(self) -> str:
        return self._name

    def set(self, value: int) -> None:
        self._position = value
        
    
class ScanningMotor:
    def __init__(self, name: str) -> None:
        self._name = name
        self._position = 0
        self._positions = list[int]()

    @property
    def name(self) -> str:
        return self._name
    
    def set(self, value: int) -> None:
        self._position = value
    
    def prepare(self, values: list[int]) -> None:
        self._positions = values

    def scan(self) -> None:
        for position in self._positions:
            self._position = position
            print(f"Scanning at position {position}")

class Camera:

    def __init__(self, name: str) -> None:
        self._name = name

    @property
    def name(self) -> str:
        return self._name
    
    def acquire(self) -> None:
        print("Acquiring image")

#devices.py end

# this controller is in charge of controlling devices that
# implement the Settable protocol and should 
# skip devices that do not implement the protocol
T = TypeVar('T', bound=Device)

def filter_devices(devices: list[Device], device_type: type[T]) -> list[T]:
    return [device for device in devices if isinstance(device, device_type)]

objs: list[Device] = [Motor("Motor1"), ScanningMotor("ScanningMotor1"), Camera("Camera1")]

motors = filter_devices(objs, Settable)
print(motors)
# controller.py end

The actual device interfaces don’t have strict inheritance from specific protocols, this happens at initialization of the control layer after all devices have been constructed.

The control layer doesn’t change, but the devices can change - albeit maintaining the same methods / attributes. The consistency of the general API of the device layer is a guarantee that there’s always a way to filter the devices needed by the specific controller.

Effectively, this whole framework is a plugin system, and each controller component can be shipped separately - hence the need for protocols.

Now, the filter_devices function can simply be swapped for a direct list comprehension and it would work fine, but to reduce boilerplate code for simple use cases one could simply do

class Controller(Generic[P]):

    def __init__(self, devices: list[Device], device_type: type[P]) -> None:
        self.devices = self.filter_devices(devices, device_type)

    def filter_devices(self, devices: list[Device], device_type: type[P]) -> list[P]:
        return [device for device in devices if isinstance(device, device_type)]

which at runtime behaves as intended but at type checking with mypy produces the error (and again, mypy is formally correct, but it’s the spec that is too restrictive).

Your suggestion would be to replace Protocol with ABC; how exactly?

EDIT: at any rate, this is a specific use tied to isinstance but it’s besides the point; the point is the current specification which seems to restrictive. And reviewing again the GitHub issue there was an user suggesting an Interface type from which type can subclass to: Interface can’t be a callable but type can’t, and I’m leaning more towards this solution because I can still see some value in the current specification and it would be less of a backwards incompatible change.

Very interesting - thanks :). I’m not sure why, but the ABC suggestion doesn’t actually work on your full example, so it’s of no use. But all I did was import ABC as Protocol, and implement the two stubs:

from abc import ABC as Protocol
from typing import TypeVar

class BaseProtocol(Protocol):
    def method(self) -> str: raise NotImplementedError

class MyProtocol(BaseProtocol, Protocol):
    def other_method(self) -> int: raise NotImplementedError

class MyClass(BaseProtocol):
    def method(self) -> str:
        return "Hello"
    
class MyOtherClass(BaseProtocol):
    def method(self) -> str:
        return "World"
    
    def other_method(self) -> int:
        return 42

T = TypeVar('T', bound=BaseProtocol)

def filter_objects(objects: list[BaseProtocol], obj_type: type[T]) -> list[T]:
    return [obj for obj in objects if isinstance(obj, obj_type)]


objs: list[BaseProtocol] = [MyClass(), MyOtherClass()]

# up until now, no problems
# the next line is the problematic one
filtered = filter_objects(objs, MyProtocol)
for obj in filtered:
    obj.other_method()

There aren’t any abstracmethods in this snippet, so being an ABC instance doesn’t guarantee much. And I’m not sure the full example will work similarly, as the two Motors and Camera don’t inherit from Device?

They do, but structurally.

for dev in devices:
    print(isinstance(dev, Device))

This will print true for each element, because each class implements the name read-only attribute.

Inheritance from protocols is useful to enforce the implementation of abstract methods/attributes, but it’s not mandatory per se. It does make the code more readable, so usually I tend to inherit from them anyway.

Oh nice one - thanks. I didn’t realise Protocols were based on ABCs, so you can also use them to enforce abstractmethods etc., as well as ABCs.