How can I dynamically extend arguments of a method?

Question:

The methods should be callable by assigning the specific parameters as well as considering the class’ attributes.

So what I like to achieve is overwriting a method’s arguments with preset attributes.

E.g.:

class Obj:
    def __init__(self, cfg=None):
        self.cfg = {}
        if cfg:
            self.cfg = cfg
        return
    
    def _actual_f_1(self, x, **kwargs):
        return x+100
    
    def f_1(self, x):
        new = {"x": x, **self.cfg}
        return self._actual_f_1(**new)
o = Obj()
o.f_1(1)

which prints

101

OR using the overriding approach:

o = Obj({"x": 100})
o.f_1(1)

which now gives

200

The defined class Obj looks pretty clumsy. Especially if several methods of the class should use the described logic.

How can I generalize the logic of f_1 which basically only alters the parameters before calling the actual method?

Asked By: MrBlueberry

||

Answers:

I am interpreting "generalize" as "write this with fewer lines of code."

You wrote

        self.cfg = {}
        if cfg:
            self.cfg = cfg
        return

The 4th line is superfluous and can be elided.

The first 3 lines could be a simple assignment of ... = cfg or {}

Or we could inherit from a utility class,
and make a super().__init__ call.


So now we’re presumably down to DRYing up and condensing these two lines:

        new = {"x": x, **self.cfg}
        return self._actual_f_1(**new)

Maybe write them as

        return self._actual_f_1(self.args())

where the parent
abstract
class offers an args helper that knows about self.cfg.

It would inspect
the stack to find the caller’s arg signature,
and merge it with cfg.

Alternatively you could phrase it as

        return self.call(self._actual_f_1)

though that seems less convenient.


Do let us know
the details of how you wind up resolving this.

Answered By: J_H

You can use __init_subclass__ in a base class to decorate all methods in a class, in a transparent way, to pick the fitting named parameters form a .cfg dict if they are not passed.

So, first let’s think of the code for such a decorator – applying arguments can be rather complicated because among positional X named parameters with "positional only" and "named only" in the mix, the number of combinationx explode

Python’s stdlib have a signature call which returns a Signature object with enough functionality to cover all corner cases. I use it, and just a common path – so that if the arguments you want to apply automatically are normal positional_or_keyword or keyword_only parameters, it should work:

from functools import wraps

def extender(method):
    sig = inspect.signature(method)
    @wraps(method)
    def wrapper(self, *args, **kwargs):
        bound = sig.bind_partial(*args, **kwargs)
        for parameter in sig.parameters.keys():
            if parameter not in bound.arguments and parameter in getattr(self, "cfg", {}):
                kwargs[parameter] = self.cfg[parameter]
        return method(self, *args, **kwargs)
    return wrapper

Here we can see that working:

In [78]: class A:
    ...:     def __init__(self):
    ...:         self.cfg={"b": 5}
    ...:     @extender
    ...:     def a(self, a, b, c=10):
    ...:         print( a, b, c)
    ...: 

In [79]: a = A()

In [80]: a.a(1)
1 5 10

In [81]: a.a(1, c=2)
1 5 2

In [82]: a.a(1, c=2, b=3)
1 3 2

With only this decorator, your code could be rewritten as:

class Obj:
    def __init__(self, cfg=None):
        self.cfg = cfg if cfg is not None else {"extra": 10}

    @extender    
    def f_1(self, x, extra):
        return x+100

And if you want a base-class that will transparently wrap all methods in all subclasses with the extender, you can use this:

class Base:
    def __init_subclass__(cls, *args, **kwargs):
        super().__init_subclass__(*args, **kwargs)
        for name, value in cls.__dict__.items():
            if callable(value):
                setattr(cls, name, extender(value))

And now one can use simply:


In [84]: class B(Base):
    ...:     def __init__(self):
    ...:         self.cfg = {"c": 10} 
    ...:     def b(self, a, c):
    ...:         print(a, c)
    ...: 

In [85]: B().b(1)
1 10

This decorator, unlike your example, takes care to just inject the arguments the function expects to receive, and not all of the keys from self.cfg in every function call.

If you want that behavior instead, you just have to take care to expand the cfg dict first and then updating it with the passed arguments, so that passed arguments will override default values in the cfg. The decorator code for that would be:

from functools import wraps

def extender_kw(method):
    sig = inspect.signature(method)
    @wraps(method)
    def wrapper(self, *args, **kwargs):
        bound = sig.bind_partial(*args, **kwargs)
        kwargs = bound.arguments
        kwargs |= getattr(self, "cfg", {})
        return method(self, **kwargs)
    return wrapper
Answered By: jsbueno
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.