typing icon indicating copy to clipboard operation
typing copied to clipboard

Merging/Modifying Types

Open ZeroIntensity opened this issue 1 year ago • 3 comments

I have a decorator that modifies a class at runtime, kind of like a dataclass:

@modify
class Something:
    ...

Let's say modify adds foo onto Something, so after calling modify you could use it like so:

@modify
class Something
    ...

bar = Something.foo()

dataclass_transform doesn't support this kind of behavior at all. So, how would an API like this work?

I don't see why it would be difficult to implement some sort of Merge type, that takes in a type and "applies"" a Protocol to it. For the above example, it would work like this:

T = TypeVar("T")

class FooProtocol(Protocol):
    @classmethod
    def foo() -> None:
        ...

def modify(tp: type[T]) -> Merge[T, FooProtocol]:
    tp.foo = ...  # type: ignore
    return tp

@modify
class Something:
    ...

bar = Something.foo()

In the above example, ideally it would just sort of mark that the T is now compatible with FooProtocol, but a type: ignore is used inside of modify because it would probably be a mess to try and come up with some sort of API for actually converting a T to Merge[T, ...].

TL;DR A type that marks another type as being compatible with a protocol could be handy for making dataclass-like decorators.

ZeroIntensity avatar Oct 17 '23 18:10 ZeroIntensity

What you're looking for here is an "intersection type". It's the converse of a "union type". PEP 483 contemplated adding intersections to the type system but deferred it until later. The idea has recently been revived, but the feature is not easy to spec or implement.

In the meantime, let's see if there's a workaround that suits your needs.

I'll note that your class decorator is directly mutating the class it's decorating, which is generally not something I would recommend doing. It would be better to create a new class object and modify it rather than directly mutating the original class. This can lead to surprises if the decorator is applied separately.

class SomethingWithoutFoo: ...

SomethingWithFoo = modify(SomethingWithoutFoo) # This mutates the original SomethingWithoutFoo

Have you considered using a mix-in class rather than a decorator? The mix-in design pattern is well established and supported in Python, and static type checkers can reason about it. It's no more verbose than a decorator, and it doesn't require any fragile mutations. It's also safer because a static type checker can tell you if you're inadvertently overriding an attribute in an incompatible manner (e.g. if the Something class happens to have an attribute named foo that is used for some other purpose).

class FooMixin:
    @classmethod
    def foo(cls) -> None:
        ...

class Something(FooMixin):
    pass

erictraut avatar Oct 17 '23 19:10 erictraut

I have considered mixins, but it's not exactly ideal when you want to do more with the class than just modify it. For example, with a dataclass-like situation, I don't think the following would be very clear:

class Foo(FooDataclass):
    a: str
    b: str

I guess you could resort to something like:

@modify
class Foo(FooMixin):
    a: str
    b: str

But in my eyes that's too much boilerplate (especially if you're planning on doing it a lot). It is subjective, though. Is there a PEP on intersections yet?

ZeroIntensity avatar Oct 18 '23 02:10 ZeroIntensity

I have posted something similar, it would help during dynamic class overloads #1511

ViktorSky avatar Nov 24 '23 00:11 ViktorSky