Mypy: filtering mapping of objects based on typing.Protocol as a TypeVar input

I’m trying to write a function that filters an input mapping to return a list of objects that implement a specific input protocol. The function looks like this:

from typing import TypeVar
from mypackage import ModelProtocol

P = TypeVar("P", bound=ModelProtocol)

def filter_models(
    models: Mapping[str, ModelProtocol],
    proto: type[P],
    choices: Sequence[str] | None = None,
) -> list[P]:
    """Filter models by a specific protocol type.

    Parameters
    ----------
    models : ``Mapping[str, ModelProtocol]``
        Mapping of model names to model instances.
    proto : ``type[Any]``
        The protocol type to filter for.
    choices : ``Sequence[str]``, optional
        If provided, return only models associated with names in this sequence.
        Default is ``None`` (all ``proto`` models are returned).

    Returns
    -------
    list[P]
        List of model instances that implement the given protocol.
    """
    if choices is not None:
        return [
            model
            for name, model in models.items()
            if isinstance(model, proto) and name in choices
        ]
    return [model for model in models.values() if isinstance(model, proto)]

It’s usage should be:

myobjs = filter_models(models, CustomProtocol)

where models is of type Mapping[str, ModelProtocol]. It is always guaranteed that CustomProtocol inherits ModelProtocol.

mypy can correctly infer that myobjs is of type list[CustomProtocol] but then gives the following error:

Only concrete class can be given where "type[CustomProtocol]" is expected

Other than suppressing this error in the configuration, isn’t there any other way to express to mypy that this is fine?

You’ll need a TypeForm[P] instead of type. The difference is that type requires things to be an actual class (and in particular a non-abstract class which could have its constructor called), while TypeForm allows any valid type. The PEP’s in draft state, but an experimental implementation was added to the development builds of Mypy.

Even with TypeForm, the implementation of your function is unlikely to pass type checking. You’ll need to check the object passed in is actually a protocol, and I’d probably recommend not relying on isinstance(x, protocol), that just checks each method exists, it’s not particularly robust.

1 Like

That should be easily fixable, thanks for pointing that out.

I haven’t looked hard enough for a suitable alternative but it seems to me that to enforce a more strict check there’s not a lot that I can do, or is there some built-in or 3rd party solution that provides a more robust check on that?

To do it robustly you’d need to examine the signatures of methods, which involves essentially implementing parts of a type checker to resolve generic types and match arguments. There are libraries that do this in general like typeguard, trycast and beartype, but given the complexity of functions in Python, it looks like they all only have basic support right now.

I don’t know your context, probably a simpler check would be sufficient.

@davidfstr this is the original topic. My confusion on using TypeForm is due to the reply of @TeamSpen210 which states that TypeForm should accept protocols as well (which is also what the PEP states technically, right?), while it seems it does not.

I’m aware that this check is not very strict and isinstance as a check is very shallow, and as correctly stated by Spencer a type checker should be more accurate; but at the same time it’s the only use case I found so far and it seems overkill to me to use a type checker just for a method, when I believe the solution can be (and should be) provided by the built-in typing system.

A minimal complete example:

from typing_extensions import TypeVar, Mapping, Protocol, Sequence, runtime_checkable

@runtime_checkable
class ModelProtocol(Protocol):
    """A protocol that all models should implement."""
    @property
    def name(self) -> str:
        ...

@runtime_checkable
class SubModelProtocol(ModelProtocol, Protocol):
    """A more specific protocol that some models may implement."""
    def specific_method(self) -> None:
        ...

P = TypeVar("P", bound=ModelProtocol)

def filter_models(
    models: Mapping[str, ModelProtocol],
    proto: type[P],
    choices: Sequence[str] | None = None,
) -> list[P]:
    """Filter a map of models by a specific protocol type.

    Parameters
    ----------
    models : ``Mapping[str, ModelProtocol]``
        Mapping of model names to model instances.
    proto : ``type[Any]``
        The protocol type to filter for.
    choices : ``Sequence[str]``, optional
        If provided, return only models associated with names in this sequence.
        Default is ``None`` (all ``proto`` models are returned).

    Returns
    -------
    list[P]
        List of model instances that implement the given protocol.
    """
    if choices is not None:
        return [
            model
            for name, model in models.items()
            if isinstance(model, proto) and name in choices
        ]
    return [model for model in models.values() if isinstance(model, proto)]

class ExampleModel:
    """An example model implementing ModelProtocol."""
    def __init__(self, name: str):
        self._name = name

    @property
    def name(self) -> str:
        return self._name
    
class ExampleSubModel:
    """An example sub-model implementing SubModelProtocol."""
    def __init__(self, name: str):
        self._name = name

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

    def specific_method(self) -> None:
        print(f"Specific method called on {self._name}")


objs: Mapping[str, ModelProtocol] = {
    "model1": ExampleModel("Model 1"),
    "submodel1": ExampleSubModel("SubModel 1"),
}

filtered = filter_models(objs, SubModelProtocol)

assert len(filtered) == 1

At runtime this works as intended. When running mypy, I get type-abstract error (which as I said, I can disable, but I don’t think I should).

When swapping proto: type[P] with proto: TypeForm[P], it still works as intended at runtime (not surprising), but mypy complains:

Argument 2 to "isinstance" has incompatible type "TypeForm[P]"; expected "_ClassInfo"

So to me something is weird. I understand that TypeForm is something (from what I can understand) dedicated to runtime type checkers, but given how isinstance and Protocols are tied together it feels something is amiss.

Ok, apparently I’m encountering the same problem discussed in the AbstractType discussion…

… and the overall discussion about removing type-abstract as an error code.

So no, TypeForm won’t help in this matter

1 Like

I think I understand the context now.

You have Protocols with the @runtime_checkable decorator. That opts them to work in isinstance on a best-effort basis. As Spencer mentions:

I’d probably recommend not relying on isinstance(x, protocol) , that just checks each method exists, it’s not particularly robust.

Let’s assume you continue using isinstance for now, rather than upgrading to a more-precise check from a runtime type checker.

Now why would mypy error on isinstance(x, proto) when proto: type[P], P = TypeVar("P", bound=ModelProtocol), and ModelProtocol is marked as @runtime_checkable?

I’m pretty sure the answer is that @runtime_checkable (specifically) isn’t currently observable in Python’s typing system. So mypy sees something like P = TypeVar("P", bound=SomeProtocolThatMayNotBeRuntimeCheckable). In this situation I think the correct thing to do is put a # type: ignore[some-error-code] on the isinstance line, since I can prove to myself that use of isinstance is correct here even though mypy isn’t smart enough to.


TypeForm should accept protocols as well (which is also what the PEP states technically, right?), while it seems it does not.

isinstance only accepts a type[T], not a TypeForm[T], so altering proto: type[P] to be proto: TypeForm[P] will cause mypy to say you’re giving an incorrectly-shaped value to isinstance. Indeed isinstance will raise an exception when given very many kinds of type forms that aren’t types.

EDIT: I re-read your answer and I understand better your point about @runtime_checkable not being seen by type[P], so yes you’re absolutely correct, but it’s a moot point which is more closely related to the relationship between static/runtime type checkers and isinstance when applied to abstract classes in general.


The scope of the problem is actually bigger than it seems. Sticking only to mypy as I don’t know how pyright behaves at this time, the first implies that when annotating proto: type[P] - when putting the bound on P on an explicit typing.Protocol class - the expectation is for proto to be a concrete type, so a class that is neither a pure typing.Protocol, an abc.ABC direct subclass or anything that marks a method as abstractmethod. The full scope of the problem is discussed at length in this GitHub issue which I linked above that shows that the pattern of using abstract types for runtime checking does work but is not acceptable by mypy for dubious reasons. Or rather, mypy developers are making a lot of honestly debatable assumptions on its use.

Also, the @runtime_checkable is observable, because it’s currently the only way for isinstance and issubclass to effectively perform their duties on Protocol classes.

Consider the following:

from typing_extensions import Protocol

class MyProtocolExample(Protocol):
    def method_one(self, arg1: int) -> str:
        ...

    def method_two(self, arg2: str) -> int:
        ...


class ImplementationExample:
    def method_one(self, arg1: int) -> str:
        return f"Number is {arg1}"

    def method_two(self, arg2: str) -> int:
        return len(arg2)
    

print(isinstance(ImplementationExample(), MyProtocolExample))  # Should print: True

At runtime it will fail with the following traceback:

Traceback (most recent call last):
  File "C:\sandbox\python\typeform_example.py", line 19, in <module>
    print(isinstance(ImplementationExample(), MyProtocolExample))  # Should print: True
  File "C:\sandbox\python\.venv\lib\site-packages\typing_extensions.py", line 730, in __instancecheck__
    raise TypeError("Instance and class checks can only be used with"
TypeError: Instance and class checks can only be used with @runtime_checkable protocols

When applying runtime_checkable to MyProtocolExample, it will work.

As Spencer mentions:

I’d probably recommend not relying on isinstance(x, protocol) , that just checks each method exists, it’s not particularly robust.

You’ll be surprised to know that, actually, this is how some runtime type checkers do the check for protocols behind the scenes - or rather, the ones I consider the most stable, mantained, used and that support the typing.Protocol checks. So far I identified only typeguard and beartype.

For the first, for example, it explicitily states here:

typing.Protocol: run-time protocols are checked with isinstance() [ndr: annotated via @runtime_checkable], others are ignored

I also asked in beartype here, and I strongly suspect the answer is the same.

That’s why TypeForm could eventually fill the gap in this situation: as type[P] expects a concrete type class (so nothing marked as abstract), TypeForm[P] could be used for generic abstract classes that are not to be instantiated but used as a reference template to check against.

My concern is that I misunderstood the intent of PEP 747, because the behavior described above would impact how isinstance works, and maybe that’s the job of another PEP to try and address. But as it stands, the current PEP 747 draft was a bit misleading for me, in particular in this point, in reference to the example of trycast(MyProtocol, obj)

And to be clear, the only true solution at this time (for my use case at least) is the one you currectly suggested of simply applying # type: ignore to the specific line that causes this issue, and since its scoped to this function only it’s not a big deal. But I can’t predict how this limitation will impact future development, and given how Protocol classes are treated by some runtime type checker at this time, it will impact them as well - and it is currently impacting them.