Konrad Rudolph
Konrad Rudolph

Reputation: 545865

How can I automatically apply a class decorator to all subclasses?

I have the following code:

from dataclasses import dataclass


class Expr:
    def __repr__(self):
        fields = ', '.join(f'{v!r}' for v in self.__dict__.values())
        return f'{self.__class__.__name__}({fields})'


@dataclass(repr=False)
class Literal(Expr):
    value: object


@dataclass(repr=False)
class Binary(Expr):
    lhs: Expr
    op: str
    rhs: Expr

This code allows me to define expression trees and to match against them, e.g.:

def fmt_ast(expr: Expr) -> str:
    match expr:
        case Literal(x):
            return str(x)
        case Binary(lhs, op, rhs):
            return f'({op} {fmt_ast(lhs)} {fmt_ast(rhs)})'
    assert False, 'Unreachable'


expr = Binary(Literal(42), '+', Literal(23))
print(fmt_ast(expr))
# (+ 42 23)

However, I would like to avoid having to decorate each Expr subclass manually. I.e. I want to skip writing @dataclass(repr=False). Based on another answer, I rewrote Expr as follows:

class Expr:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        dataclass(repr=False)(cls)

    def __repr__(self):
        fields = ', '.join(f'{v!r}' for v in self.__dict__.values())
        return f'{self.__class__.__name__}({fields})'

… and this seems to work (even after removing the decorators from the subclasses). The thing is, I don’t understand why it works: sure, I’m invoking the dataclass decorator but I’m not doing anything with the result, i.e.with the decorated type. I don’t assign it to anything (to what?!) and I don’t return it, since __init_subclass__ has no meaningful return value. And yet, the code continues to run as if the subclasses had been decorated with dataclass.

However, mypy (0.942) calls my bluff:

test/__init__.py:29: error: Class "test.Literal" doesn't define "__match_args__"
test/__init__.py:31: error: Class "test.Binary" doesn't define "__match_args__"
test/__init__.py:36: error: Too many arguments for "Binary"
test/__init__.py:36: error: Too many arguments for "Literal"

I believe that mypy is correct, which leaves me with two questions:

  1. Why does this seem to work at all?
  2. How to do this properly, i.e. in a way that satisfies mypy, without reimplementing the dataclass functionality from scratch inside Expr or its metaclass?

Upvotes: 2

Views: 1110

Answers (1)

jsbueno
jsbueno

Reputation: 110456

@juanpa.arrivillaga has explained in the comments perfectly what is taking place, as put:

  • a dataclass call modifies the class in place: the same object is mutated so that it gains the new methods and new behaviors.
  • mypy can't know about the new methods, because it does not run the code: it special cases the dataclass decorator, so that in ordinary uses it does know about the new attributes. The workaround there would be adding the methods that dataclass create to Repr so that mypy would be fooled.
  • some calls to dataclass might return a new object, like when using the new slots parameter, in that case, __init_subclass__ won't help, as dataclass returns a new class, built with base on the one that was passed to it. For the same approach to work, you will have to use a metaclass, and override its __new__ method, instead (or along with) ordinary inheritance. The metaclass.__new__ method has to return the actual object that will be used as the class.
def __repr__(self):
   # ...
   # stand alone method which will be injected by the metaclass


class MExpr(type):
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        cls.__repr__ = __repr__
        return dataclass(repr=False, slots=...)(cls)  

class Literal(metaclass=MExpr):
    ...

Upvotes: 2

Related Questions