Elegant way to pass arguments to super().__init__ while keeping their name in the signatures?

Viewed 128
class A:
    def __init__(self, x1, x2, x3, x4):
        pass
        ...

class B(A):
    def __init__(self, x1, x2, x3, x4, y1, y2):
        super().__init__(x1, x2, x3, x4)  # How to replace this without repeating the argument names?
        ...

I prefer not to use **kwargs or dicts, as it hides the actual signature.

One solution could be something like:

from inspect import signature

class B(A):
    def __init__(self, x1, x2, x3, x4, y1, y2):
        sig = signature(super().__init__)
        params = {k: v for k, v in locals().items() if k in sig.parameters}
        super().__init__(**params)
        ...

But this is cumbersome and has its own problems. Is there anything more elegant that could be done?

Edit: The motivation to this question is the DRY principle.

2 Answers

Expanding on my comment:

It sounds like x1, x2, x3, x4 are logically grouped in that you see their names repeating together throughout your hierarchy of classes. You can use a tuple to rid of the repetition. An even better solution would be a @dataclass, preferably read-only if the logical grouping is established at initialization time, and shouldn't be changed after. Both of these solutions would support heterogeneous types.

from dataclasses import dataclass

@dataclass(frozen=True)
class X:
    x1: ...
    x2: ...
    x3: ...
    x4: ...

Note that you don't need to use a @dataclass here, really any class with an __init__ method for aggregating this data and assigning it to self attributes would work. But @dataclass provides some nifty features here, see link and comments.

If the types of x1 ... x4 are homogenous, and you don't need to particularly enforce that there are 4 of these xs, you can use a list if you need indexing capability, otherwise set (and even frozenset to enforce this grouping to be done only at initialization time).

Point is that there is a clever solution out there to do what you want, but using structures to aggregate your data here will naturally get rid of repetition. This structure would be under a single name, and you would only have to pass this one argument to super().__init__.

I understand that usually this question is to be rather avoided - better use a dataclass as per Mario's answer, or just write the arguments again as suggested in the comments.

But for the sport and the chance that this will actually be useful to someone someday, I came up with the following solution:

import inspect
from functools import wraps


def calling_base_init(init):
    @wraps(init)
    def wrapper(*args, **kwargs):
        sig_init = inspect.signature(init)
        bargs = sig_init.bind(*args, **kwargs)
        for cls in args[0].__class__.__bases__:
            sig = inspect.signature(cls.__init__)
            params = {k: v for k, v in bargs.arguments.items() if k in sig.parameters if k != 'self'}
            if len(params) == len(sig.parameters) - 1:
                cls.__init__(args[0], **params)
                break
        else:
            assert False, f"No appropriate signature was found among base classes of {args[0].__class__.__name__}"
        init(*args, **kwargs)

    return wrapper

And then use the decorator on the derived class's __init__:

class A:
    def __init__(self, x1, x2, x3, x4):
        print(x1, x2, x3, x4)


class B(A):
    @calling_base_init
    def __init__(self, x1, x2, x3, x4, y1, y2):
        print(y1, y2)


B(11, 12, 13, 14, 21, 22)

Outputs:

11 12 13 14
21 22

as expected.

Related