1

Problem

Suppose I want to implement a class decorator that adds some attributes and functions to an existing class.

In particular, let's say I have a protocol called HasNumber, and I need a decorator can_add that adds the missing methods to convert HasNumber class to CanAdd.

class HasNumber(Protocol):
    num: int

class CanAdd(HasNumber):
    def add(self, num: int) -> int: ...

Implementation

I implement the decorator as follows:

_HasNumberT = TypeVar("_HasNumberT", bound=HasNumber)


def can_add(cls: Type[_HasNumberT]) -> Type[CanAdd]:
    def add(self: _HasNumberT, num: int) -> int:
        return self.num + num

    setattr(cls, "add", add)

    return cast(Type[CanAdd], cls)


@can_add
class Foo:
    num: int = 12

Error

The code works just fine when I run it, but mypy is unhappy about it for some reason.

It gives the error "Foo" has no attribute "add" [attr-defined], as if it doesn't take the return value (annotated as Type[CanAdd]) of the can_add decorator into account.

foo = Foo()
print(foo.add(4))  # "Foo" has no attribute "add"  [attr-defined]
reveal_type(foo) # note: Revealed type is "test.Foo"

Question

In this issue, someone demonstrated a way of annotating this with Intersection. However, is there a way to achieve it without Intersection? (Supposing that I don't care about other attributes in Foo except the ones defined in the protocols)

Or, is it a limitation of mypy itself?

Related posts that don't solve my problem:

PIG208
  • 2,060
  • 2
  • 10
  • 25
  • I suspect it's a shortcoming of `mypy`. `cast` suppresses any concerns that the thing being returned isn't a `CanAdd`, but there is no static guarantee provided that `add` was actually added to the `cls` argument. – chepner Aug 21 '21 at 16:17
  • As a result, `mypy` cannot confirm that `Foo` has an `add` attribute, only that you are saying it's OK for `Foo` (with or without an `add` attribute) to be the return value of `can_add`. – chepner Aug 21 '21 at 16:19
  • What I expected from mypy is that it substitutes the decorated class with the return type of `can_add`, i.e. `Type[CanAdd]`, without concerning about what's actually going on with the actual implementation (since I intentionally ignore it with `cast`). – PIG208 Aug 21 '21 at 16:32

1 Answers1

2

cast tells mypy that cls (with or without an add attribute) is safe to use as the return value for can_add. It does not guarantee that the protocol holds.

As a result, mypy cannot tell whether Foo has been given an add attribute, only that it's OK to use the can_add decorator. The fact that can_add has a side effect of defining the add attribute isn't visible to mypy.

You can, however, replace the decorator with direct inheritance, something like

class HasNumber(Protocol):
    num: int


_HasNumberT = TypeVar("_HasNumberT", bound=HasNumber)

class Adder(HasNumber):
    def add(self, num: int) -> int:
        return self.num + num

class Foo(Adder):
    num: int = 12

foo = Foo()
print(foo.add(4))
chepner
  • 497,756
  • 71
  • 530
  • 681
  • Yes, inheritance might be the best solution here. Perhaps there will be better support to class decorators in the future. (As a side-note reconfirming that it might be a mypy limitation, Pylance correctly types the decorated class) – PIG208 Aug 21 '21 at 17:10