CarliJoy / intersection_examples

Python Typing Intersection examples
MIT License
33 stars 2 forks source link

Sample use case: Intersection between protocol and concrete type #47

Open rayansostenes opened 8 months ago

rayansostenes commented 8 months ago
from collections.abc import Iterable
from typing import Protocol, Self

from django.db import models
from django.db.models.query import QuerySet

class SupportsActiveProtocol(Protocol):
    def make_active(self) -> None: ...

    def make_inactive(self) -> None: ...

    @classmethod
    def query_active(cls) -> Iterable[Self]: ...

class Person(models.Model):
    name = models.CharField(max_length=100)
    is_active = models.BooleanField(default=True)

    def make_active(self):
        self.is_active = True

    def make_inactive(self):
        self.is_active = False

    @classmethod
    def query_active(cls) -> QuerySet[Self]:
        return cls.objects.filter(is_active=True).all()

Suppose that we have a function called activate that can take any django model that implements the SupportsActiveProtocol and make it active.

def activate(person: SupportsActiveProtocol):
    if isinstance(person, models.Model):
        # Pyright will infer the correct type for "person"
        reveal_type(person) # Type of "person" is "<subclass of SupportsActiveAndSaveProtocol and Model>"

        person.make_active()
        person.save()
    raise ValueError("Invalid type")

Calling it with a Person object will work at runtime, and it type checks.

activate(Person.objects.get(id=1))

But calling it with another object that also implements the SupportsActiveProtocol will fail at runtime, because we use the Model.save method, which is not part of the protocol.

class FakePerson:
    def make_active(self): ...
    def make_inactive(self): ...
    @classmethod
    def query_active(cls) -> Iterable[Self]: ...

activate(FakePerson()) # ValueError: Invalid type

Right now the only way to fix this is to add a save method to the protocol, or create a new protocol like SupportsSaveProtocol and create a third protocol that extends both SupportsActiveProtocol and SupportsSaveProtocol.

class SupportsSaveProtocol(Protocol):
    def save(self) -> None: ...

class SupportsActiveAndSaveProtocol(SupportsActiveProtocol, SupportsSaveProtocol):
    ...

def activate(person: SupportsActiveAndSaveProtocol):
    if isinstance(person, models.Model):
        person.make_active()
        person.save()
    raise ValueError("Invalid type")

activate(FakePerson()) # Fails with:
# Argument of type "FakePerson" cannot be assigned to parameter "person" of type "SupportsActiveAndSaveProtocol" in function "activate"

This can become very cumbersome to maintain, and it's not very DRY, since we may ending up creating a protocol that contains all the methods of the model.

The addition of a Intersection type would make it possible to solve this problem in a more elegant way.

def activate(person: SupportsActiveProtocol & models.Model):
    person.make_active()
    person.save()
shadycuz commented 2 weeks ago

This is pretty much my exact use case but I hope it will also work with TypeVar. It seems like Pylance understands whats going on if you check it with isinstance as I show in https://github.com/python/typing/discussions/1881

It would be nice to have something like...

T = TypeVar["T", bound=models.Model)
ActiveModel = T & SupportsActiveProtocol