Type hinting a class decorator that returns a subclass

Viewed 74

I have a set of unrelated classes (some imported) which all have a common attribute (or property) a of type dict[str, Any].

Within a there should be another dict under the key "b", which I would like to expose on any of these classes as an attribute b to simplify inst.a.get("b", {})[some_key] to inst.b[some_key].

I have made the following subclass factory to work as a class decorator for local classes and a function for imported classes.

But so far I'm failing to type hint its cls argument and return value correctly.

from functools import wraps

def access_b(cls):
    @wraps(cls, updated=())
    class Wrapper(cls):
        @property
        def b(self) -> dict[str, bool]:
            return self.a.get("b", {})
    return Wrapper

MRE of my latest typing attemp (with mypy 0.971 errors):

from functools import wraps
from typing import Any, Protocol, TypeVar

class AProtocol(Protocol):
    a: dict[str, Any]

class BProtocol(AProtocol, Protocol):
    b: dict[str, bool]

T_a = TypeVar("T_a", bound=AProtocol)
T_b = TypeVar("T_b", bound=BProtocol)

def access_b(cls: type[T_a]) -> type[T_b]:
    @wraps(cls, updated=())
    class Wrapper(cls):  # Variable "cls" is not valid as a type & Invalid base class "cls"
        @property
        def b(self) -> dict[str, bool]:
            return self.a.get("b", {})
    return Wrapper

@access_b
class Demo1:
    """Local class."""
    def __init__(self, a: dict[str, Any]):
        self.a = a.copy()

demo1 = Demo1({"b": {"allow_X": True}})
demo1.b["allow_X"]  # "Demo1" has no attribute "b"

class Demo2:
    """Consider me an imported class."""
    def __init__(self, a: dict[str, Any]):
        self.a = a.copy()

demo2 = access_b(Demo2)({"b": {"allow_X": True}})  # Cannot instantiate type "Type[<nothing>]"
demo2.b["allow_X"]
  1. I do not understand why cls is not valid as a type, even after reading https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases.

  2. I understand I should probably not return a Protocol (I suspect that is the source of Type[<nothing>]), but I don't see how I could specify "returns the original type with an extension".

PS1. I have also tried with a decorator which adds b dynamically, still failed to type it...

PS2. ...and with a decorator which uses a mixin as per @DaniilFajnberg's answer, still failing.


References:

  1. functools.wraps(cls, update=()) from https://stackoverflow.com/a/65470430/17676984
1 Answers

(Type) Variables as base classes?

This is actually a really interesting question and I am curious about what solutions other people come up with.

I read up a little on these two errors:

Variable "cls" is not valid as a type / Invalid base class "cls"

There seems to be an issue here with mypy that has been open for a long time now. There doesn't seem to be a workaround yet.

The problem, as I understand it, is that no matter how you annotate it, the function argument cls will always be a type variable and that is considered invalid as a base class. The reasoning is apparently that there is no way to make sure that the value of that variable isn't overwritten somewhere.

I honestly don't understand the intricacies well enough, but it is really strange to me that mypy seems to treat a class A defined via class A: ... different than a variable of Type[A] since the former should essentially just be syntactic sugar for this:

A = type('A', (object,), {})

There was also a related discussion in the mypy issue tracker. Again, hoping someone can shine some light onto this.


Adding a convenience property

In any case, from your example I gather that you are not dealing with foreign classes, but that you define them yourself. If that is the case, a Mix-in would be the simplest solution:

from typing import Any, Protocol


class AProtocol(Protocol):
    a: dict[str, Any]


class MixinAccessB:
    @property
    def b(self: AProtocol) -> dict[str, bool]:
        return self.a.get("b", {})


class SomeBase:
    ...


class OwnClass(MixinAccessB, SomeBase):
    def __init__(self, a: dict[str, Any]):
        self.a = a.copy()


demo1 = OwnClass({"b": {"allow_X": True}})
print(demo1.b["allow_X"])

Output: True

No mypy issues in --strict mode.


Mixin with a foreign class

If you are dealing with foreign classes, you could still use the Mix-in and then use functools.update_wrapper like this:

from functools import update_wrapper
from typing import Any, Protocol


class AProtocol(Protocol):
    a: dict[str, Any]


class MixinAccessB:
    """My mixin"""
    @property
    def b(self: AProtocol) -> dict[str, bool]:
        return self.a.get("b", {})


class Foreign:
    """Foreign class documentation"""
    def __init__(self, a: dict[str, Any]):
        self.a = a.copy()


class MixedForeign(MixinAccessB, Foreign):
    """foo"""
    pass


update_wrapper(MixedForeign, Foreign, updated=())


demo2 = MixedForeign({"b": {"allow_X": True}})
print(demo2.b["allow_X"])
print(f'{MixedForeign.__name__=} {MixedForeign.__doc__=}')

Output:

True
MixedForeign.__name__='Foreign' MixedForeign.__doc__='Foreign class documentation'

Also no mypy issues in --strict mode.

Note that you still need the AProtocol to make it clear that whatever self will be in that property follows that protocol, i.e. has an attribute a with the type dict[str, Any].


I hope I understood your requirements correctly and this at least provides a solution for your particular situation, even though I could not enlighten you on the type variable issue.

Related