@delegates decorator for multiple inheritance

In case anybody runs into the same issue: Jeremy’s cool @delegates decorator (introduced here) nicely ports over **kwargs arguments between classes and functions. However it doesn’t work with multiple inheritance.

However with a small tweak you can get it to work:

def delegates(to=None, keep=False):
    "Decorator: replace `**kwargs` in signature with params from `to`"
    def _f(f):
        from_f = f.__init__ if to is None else f
        sig = inspect.signature(from_f)
        sigd = dict(sig.parameters)
        k = sigd.pop('kwargs')
        if to is None:
            for base_cls in f.__bases__:
                to_f = base_cls.__init__
                s2 = {k:v for k,v in inspect.signature(to_f).parameters.items()
                    if v.default != inspect.Parameter.empty and k not in sigd}
                sigd.update(s2)
        else:
            to_f = to
            s2 = {k:v for k,v in inspect.signature(to_f).parameters.items()
                if v.default != inspect.Parameter.empty and k not in sigd}
            sigd.update(s2)
        if keep: sigd['kwargs'] = k
        from_f.__signature__ = sig.replace(parameters=sigd.values())
        return f
    return _f
7 Likes

I was wondering if the actual version from fastai (2024-10-30 ) had integrated something from here.

As this is above me, I asked … you know.

Here are the answers.

A: OegeDijk

B: current fastcore as of 2024-10-30

BTW, it took me a while to find some good documentation about @delegates

================

What are the differences between

A

def delegates(to=None, keep=False):
    "Decorator: replace `**kwargs` in signature with params from `to`"
    def _f(f):
        from_f = f.__init__ if to is None else f
        sig = inspect.signature(from_f)
        sigd = dict(sig.parameters)
        k = sigd.pop('kwargs')
        if to is None:
            for base_cls in f.__bases__:
                to_f = base_cls.__init__
                s2 = {k:v for k,v in inspect.signature(to_f).parameters.items()
                    if v.default != inspect.Parameter.empty and k not in sigd}
                sigd.update(s2)
        else:
            to_f = to
            s2 = {k:v for k,v in inspect.signature(to_f).parameters.items()
                if v.default != inspect.Parameter.empty and k not in sigd}
            sigd.update(s2)
        if keep: sigd['kwargs'] = k
        from_f.__signature__ = sig.replace(parameters=sigd.values())
        return f
    return _f

B

def delegates(to:FunctionType=None, # Delegatee
              keep=False, # Keep `kwargs` in decorated function?
              but:list=None): # Exclude these parameters from signature
    "Decorator: replace `**kwargs` in signature with params from `to`"
    if but is None: but = []
    def _f(f):
        if to is None: to_f,from_f = f.__base__.__init__,f.__init__
        else:          to_f,from_f = to.__init__ if isinstance(to,type) else to,f
        from_f = getattr(from_f,'__func__',from_f)
        to_f = getattr(to_f,'__func__',to_f)
        if hasattr(from_f,'__delwrap__'): return f
        sig = inspect.signature(from_f)
        sigd = dict(sig.parameters)
        k = sigd.pop('kwargs')
        s2 = {k:v.replace(kind=inspect.Parameter.KEYWORD_ONLY) for k,v in inspect.signature(to_f).parameters.items()
              if v.default != inspect.Parameter.empty and k not in sigd and k not in but}
        anno = {k:v for k,v in getattr(to_f, "__annotations__", {}).items() if k not in sigd and k not in but}
        sigd.update(s2)
        if keep: sigd['kwargs'] = k
        else: from_f.__delwrap__ = to_f
        from_f.__signature__ = sig.replace(parameters=sigd.values())
        if hasattr(from_f, '__annotations__'): from_f.__annotations__.update(anno)
        return f
    return _f

================

These two versions of the delegates decorator serve a similar purpose — to enhance a function’s signature by updating its parameters to match those of another function (the “delegatee”). However, they differ in their design, feature set, and some implementation details. Here are the main differences between the two:

1. Parameter Flexibility

  • Version A:
    • Has two parameters: to, which is the function to delegate parameters from, and keep, which specifies whether to retain kwargs in the decorated function’s signature.
  • Version B:
    • Has three parameters: to, keep, and but.
    • to and keep have the same purpose as in Version A.
    • but is an additional parameter that allows specific parameters to be excluded from the delegation. This makes Version B more flexible in scenarios where you want to delegate only a subset of the delegatee’s parameters.

2. Type Hinting

  • Version A:
    • Does not use type hints in the function signature.
  • Version B:
    • Type-hints the to parameter to expect a FunctionType, making it clearer that to is expected to be a callable. This helps with readability and can aid in linting and type-checking.

3. Delegatee (to) Resolution

  • Version A:
    • Resolves to by checking if it’s None and, if so, attempts to use the __init__ method of any base classes of f.
  • Version B:
    • Allows for greater flexibility in specifying the delegatee. If to is provided as a class, it uses the class’s __init__ method. If it’s a callable, it uses to directly. This makes the decorator more robust in handling both functions and classes.

4. Parameter Annotations

  • Version A:
    • Does not handle or copy parameter annotations from the delegatee.
  • Version B:
    • Copies parameter annotations from the delegatee function to the decorated function. This is achieved by updating from_f.__annotations__ with anno, which collects the annotations from to_f.
    • This feature enhances code clarity and maintains documentation and type information on the decorated function.

5. Keyword-Only Parameter Conversion

  • Version A:
    • Does not convert any parameters to keyword-only.
  • Version B:
    • Explicitly converts delegated parameters to keyword-only parameters in the decorated function’s signature by using inspect.Parameter.KEYWORD_ONLY.
    • This can make the function signature more explicit, enforcing that these parameters must be specified as keyword arguments, which can improve code readability and prevent accidental positional use of delegated parameters.

6. Attributes and Caching

  • Version A:
    • Does not cache or add any custom attributes to the decorated function.
  • Version B:
    • Uses the __delwrap__ attribute to cache the delegatee function (to_f) on the decorated function (from_f).
    • This attribute acts as a flag to prevent re-wrapping the function if it’s already wrapped, providing additional safety when stacking multiple decorators or avoiding redundant wrapping.

7. Signature Update Mechanism

  • Both Versions:
    • Both modify the signature of the function from_f using from_f.__signature__ = sig.replace(parameters=sigd.values()).
  • Version B:
    • Additionally manages and updates the __annotations__ attribute to include any annotations from to_f, ensuring type hints are correctly copied over.

Summary of Key Differences:

  • Feature: Version B is more feature-rich, adding the ability to exclude specific parameters, carry over annotations, convert parameters to keyword-only, and handle caching with __delwrap__.
  • Code Complexity: Version B is more complex, but this is primarily to support additional flexibility and maintain metadata like annotations and keyword-only parameters.
  • Use Case Suitability: If you need basic delegation, Version A suffices. However, if you require exclusions (but), annotations, or keyword-only parameters, Version B is more suitable.

In conclusion, Version B is a more versatile and robust implementation of the delegates decorator, while Version A provides a simpler, more lightweight version suitable for basic delegation needs.

================

The creator of version A says However it doesn’t work with multiple inheritance. referring to version B. Is he right?

================

The creator of Version A is correct in noting that Version B may not work as expected with multiple inheritance in certain cases, depending on how it’s used. Here’s why:

Issue with Multiple Inheritance

In Python, when you use multiple inheritance, the order in which base class methods are resolved follows the Method Resolution Order (MRO). If a decorator like delegates tries to access the __init__ method from a base class without explicitly handling multiple inheritance, it can lead to unexpected behaviors. Version B assumes a single base class’s __init__ or a single to function when delegating parameters, which doesn’t account for the complexity of multiple inheritance scenarios.

Here’s where it gets specific with Version B:

  • When to is None, Version B defaults to setting to_f to f.__base__.__init__. The __base__ attribute in Python refers to the first base class only, and it doesn’t account for other base classes in cases of multiple inheritance.
  • In a multiple inheritance context, __base__ will not necessarily represent the correct __init__ method according to MRO. As a result, it may not capture parameters from all base class __init__ methods or may simply choose the wrong one.

Why Version A Might Handle This Better

In Version A:

  • The decorator iterates over all base classes (for base_cls in f.__bases__:) when to is None, allowing it to pick parameters from all base class __init__ methods.
  • This approach aligns with multiple inheritance, as it doesn’t restrict delegation to a single __init__ and instead considers the parameters of each base class in f.__bases__.

Example Case Illustrating the Problem

Consider a scenario with multiple inheritance:

class A:
    def __init__(self, a=1, **kwargs): pass

class B:
    def __init__(self, b=2, **kwargs): pass

class C(A, B):
    @delegates()
    def __init__(self, c=3, **kwargs):
        super().__init__(**kwargs)
  • With Version A: __init__ in C can inherit both a and b parameters from A and B because it iterates through C.__bases__, finding both A.__init__ and B.__init__.
  • With Version B: __init__ in C will only inherit parameters from A (since it only references __base__.__init__), completely ignoring B.__init__.

Possible Solution for Version B

To make Version B compatible with multiple inheritance, you could modify it to iterate over all base classes rather than relying solely on __base__. However, handling this gracefully would also require managing potential conflicts or overlaps in parameter names from multiple base classes.

Conclusion

Yes, the creator of Version A is correct. Version B, in its current form, does not fully support multiple inheritance due to its reliance on a single __base__ class. Version A, by iterating through all base classes, better accommodates parameters from multiple inheritance hierarchies.