klausweiss / typing-protocol-intersection

Protocols intersection for mypy

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Can I use this to define a proxy/wrapper type?

jace opened this issue · comments

Something like this:

class ProxyWrapper(Generic[T]):
    if not TYPE_CHECKING:
        def __init__(self, obj: T):
            object.__setattr(self, '_obj', obj)
        def __getattr__(self, attr) -> Any:
            # Logic to allow or deny this attr
            return getattr(self._obj, attr)
        def __setattr__(self, attr, value): ...  # Similar

    @classmethod
    def wrap(cls, obj: T2) -> Has[ProxyWrapper[T2], T2]:
        return cls(obj)

The expectation being that the proxy wrapper now has the same attributes as the type it wraps, except where explicitly overridden in the proxy. Is this possible?

Not sure what you're trying to achieve. Could you provide a more complete example of how you'd intend to use this?

The general idea is discussed in python/typing#802.

There's a recurring case of this in pre-PEP 681 descriptors across frameworks (SQLAlchemy, etc). To make this work…

class DataObject(FrameworkBaseClass):
    field = Field(...)

…the Field class is typically implemented as a descriptor with unbound and bound variants:

F = TypeVar('F', bound='Field')
T = TypeVar('T')

class Field:
    # Insert @overload variants
    def __get__(self: F, obj: T | None, cls: type[T]) -> F | BoundField[F, T]:
        if obj is None:
            return self
        return BoundField(self, obj)

class BoundField(Generic[F, T]):
    def __init__(self, field: F, obj: T) -> None: ...

    def __getattr__(self, attr: str) -> Any:  # Proxy typing needed here
        return getattr(self._field, attr)

    ...  # Insert methods that override Field's methods where necessary (eg: `__call__`)

The trouble is that without proxy typing support, DataObject().field.attr will be typed Any, while class-level access as DataObject.field.attr will identify the correct type. The current workaround is for the wrapper to explicitly declare the proxied attributes, but this will only work if (a) the wrapper is limited to specific types (as here), and (b) future subclasses of the wrapped type are not expected to add more attributes.

A clean syntax would be nice. Either __get__ returns an intersection type:

class Field:
    @overload
    def __get__(self: F, obj: None, cls: type[T]) -> F: ...
    @overload
    def __get__(self: F, obj: T, cls: type[T]) -> Has[BoundField, F]: ...

Or the wrapper is declared to be a proxy generic, appearing to follow subclass semantics only for type checking and only in a generic-bound instance:

class BoundField(Generic[Proxy[F], T]): ...

The first syntax is very close to what your plugin achieves, but using types and TypeVars instead of Protocols.

Proxying can be somewhat implemented, provided that you only operate on Protocols (this plugin will only ever work with Protocols).

A simplified example:

from typing import Protocol, Generic, TypeVar, Any

from typing_protocol_intersection import ProtocolIntersection

class Dog:
    def pet(self)  -> None: print("pet")
    def feed(self) -> None: print("feed")
    def bark(self) -> None: print("bark")

class IsAnimal(Protocol):
    def pet(self)  -> None: pass
    def feed(self) -> None: pass

class ExtraAbilities(Protocol):
    def fly(self) -> None: pass

T = TypeVar("T")
class FlyAdder(Generic[T]):
    def extend(self, t: T) -> ProtocolIntersection[T, ExtraAbilities]:
        class ExtendedT:
            def fly(self) -> None: print("fly")
            def __getattr__(self, name: str) -> Any:
                return getattr(t, name)
        return ExtendedT()

extra_animal = FlyAdder[IsAnimal]().extend(Dog())
# reveal_type(extra_animal) ->  Revealed type is "typing_protocol_intersection.types.ProtocolIntersection​​​[procy.ExtraAbilities, procy.IsAnimal]"
extra_animal.pet()
extra_animal.fly()
# extra_animal.bark() <- this will not typecheck, as `bark` is not part of neither `ExtraAbilities` or `IsAnimal` protocols, even though it is an attriubute of the originally extended instance

Also note, you could currently say extra_animal = FlyAdder[Dog]().extend(Dog()), but it is incorrect - it's a bug in the plugin I have just discovered and will be fixing soon. If you used this, you couldn't trust mypy.

I believe the answer was satisfactory. Closing.