-1

I would like to create a decorator for dynamically assigning function signatures to decorated functions, something like this:

import inspect

signature = inspect.Signature([
    inspect.Parameter('x', kind=inspect.Parameter.KEYWORD_ONLY, default=None),
    inspect.Parameter('y', kind=inspect.Parameter.KEYWORD_ONLY, default=None),
    inspect.Parameter('z', kind=inspect.Parameter.KEYWORD_ONLY, default=None)])

def with_signature(signature:inspect.Signature):
    def _decor(f):
        def _wrapper():
            ...
        return _wrapper
    return _decor


@with_signature(signature):
def fun():
    return [x, y, z]

fun should now have a signature as if it had been defined literally with def fun(x=None, y=None, z=None). Also type hints in the signature would be great.

How to go about this?

Edit

I came up with a somewhat kludgy solution:

def with_signature(signature:inspect.Signature):

    def _decor(f):
        
        @functools.wraps(f)
        def _wrapper(**kwargs):
            signature.bind(**kwargs)
        

            _f = types.FunctionType(
                f.__code__,
                kwargs
            )

            return _f()
        return _wrapper
    return _decor

This allows to write:

signature = inspect.Signature([
    inspect.Parameter('x', kind=inspect.Parameter.KEYWORD_ONLY, default=None),
    inspect.Parameter('y', kind=inspect.Parameter.KEYWORD_ONLY, default=None),
    inspect.Parameter('z', kind=inspect.Parameter.KEYWORD_ONLY, default=None)])

@with_signature(signature)
def fun():
    return [x, y, z]

print(fun(x=1, z=3, y=2))

The idea is to check **kwargs with signature.bind, then create a function and provide **kwargs as globals. This is obviously pretty ugly, and in fact doesn't do what I want, i.e. actually change the decorated function's signature. So I'd appreciate any help.

Edit 2:

The whole endeavour seems rather unpythonic to me now. What I actually want is to provide a definite function interface, so using **kwargs with typing_extension.Unpack and a typing.TypedDict might be a good way to go.

from typing import TypedDict
from typing_extensions import Unpack

class Signature(TypedDict):
    x: int = 0
    y: int = 0
    z: int = 0

def fun(**kwargs: Unpack[Signature]):
    ...

I think I'll use that for now, but still the OP problem seems interesing to me, so I'm still looking forward to suggestions and ideas.

upgrd
  • 720
  • 7
  • 16
  • Could you explain why you want to do that? Your `fun` would not make sense if x, y or z parameter would be missing, so this seems pretty error-prone. – Robin Apr 30 '23 at 07:05
  • @Robin Thanks for answering! In a project I am doing I noticed that there is a lot of duplication in the function signatures, so I came up with the idea of an 'with_signature' decorator. Of course I could just define the functions to accepts **kwargs, but this would obfuscate the actually intended function interface. I came up with a kludge, see the edit in case you are interested. – upgrd Apr 30 '23 at 07:22
  • If the reason for doing this is to avoid code duplication, I think this is not the right way to go. Even though it might be better than **kwargs, it still hides which parameters the function takes. I would either live with the duplicated parameters (code duplication there is less bad than in function bodies imo) or restructure your code, possibly merging multiple functions with the same signature and using a `match` statement, if that's a viable option. – Robin Apr 30 '23 at 07:32
  • @Robin Ok. And what would be the right way to go? I just want to avoid writing the same signature over and over. But I see that defining a decorator like the above is prone to be messy. I modelled a decorator like this in Lisp and it worked pretty well there. But Lisp actually is kind of made for meta stuff like that, so I wouldn't expect Python to shine in that regard. – upgrd Apr 30 '23 at 07:35
  • I have added an answer exploring three ways of dealing with long repeating parameter lists. Feel free to comment on it if you have any further questions. – Robin Apr 30 '23 at 08:27

2 Answers2

4

Dynamic function signatures are prone to runtime errors and reduce readability. As the OP's motivation for this is avoiding code duplication, here are some alternative ways to tackle the problem of having to repeat method parameters over and over. Which one is best depends on what you are actually doing, I have added some suggestions in which situations the options might make most sense.

Option 1: Data Object

You can pack all your parameters into a data class and only pass a single data object to each method. This is a good option if your parameters can be grouped together in a way that makes sense (e.g. text size, color, font, etc. could be described as text options)

@dataclass
class Params:  # rename this to something actually describing the parameter collection
    # you don't have to use type annotations, but it's good practice
    x: int  # required parameter
    y: int = 0
    z: int = 0

def to_list(params: Params):
    return [params.x, params.y, params.z]

def to_list_reversed(params: Params):
    return [params.z, params.y, params.x]

Option 2: Make functions instance methods

If your different functions not only take the same parameters (name + type) but you also actually call (some of) them with the same arguments (e.g. fun1(1, 2, 3) and fun2(1, 2, 3)), it might make sense to create a class and make the functions instance methods of this class.

@dataclass
class MethodExecutor:  # again, rename this to something natural that fits your context
    x: int  # required parameter
    y: int = 0
    z: int = 0

    def to_list(self):
        return [self.x, self.y, self.z]

    def to_list_reversed(self):
        return [self.z, self.y, self.x]

Option 3: Merge your functions into a single one and use a match-statement

If all your functions return the same type (e.g. list of numbers) and only do different "operations" on the inputs it could make sense to put all your functions into a single one (if they are short!) and add an operation parameter.

class Operation(IntEnum):
    TO_LIST = 0
    TO_LIST_REVERSED = 1

def process(x: int, y: int = 0, z: int = 0, operation: Operation):
    match operation:
        case Operation.TO_LIST:
            return [x, y, z]
        case Operation.TO_LIST_REVERSED:
            return [z, y, x]

Option 4: Use typed **kwargs

Recently, the Unpack[...] type has been added to some linters. This enables typing **kwargs properly, using a TypedDict. This enables using **kwargs in a safe way and eliminates the need for constructing a new instance of a data class as in Option 1. Note, however, that this is not supported by all linters yet and you get no error on passing wrong parameters. Default values are not directly supported, but can be applied when reading from the **kwargs dict.

class Params(TypedDict):  # rename to something descriptive
    x: Required[int]
    y: NotRequired[int]
    z: NotRequired[int]

def to_list(**kwargs: Unpack[Params]):
    return [kwargs['x'], kwargs.get('y', 0), kwargs.get('z', 0)]

def to_list_reversed(**kwargs: Unpack[Params]):
    return [kwargs.get('z', 0), kwargs.get('y', 0), kwargs['x']]
Robin
  • 333
  • 1
  • 12
  • Thank you very much! Also another solution could be to just go with **kwargs and use an Unpack type hint. Still looking into that though. – upgrd Apr 30 '23 at 08:48
  • I suppose you refer to [this](https://stackoverflow.com/a/37032111/18214330)? That looks great and would actually be my 1st choice if your linter supports it, didn't know it existed - I will add it to my answer! :) – Robin Apr 30 '23 at 10:12
  • Yes, exactly! I went with this approach now, I think this is the most pythonic way to do it. – upgrd Apr 30 '23 at 11:52
  • 1
    For option 4, this seems to be only a feature of Python3.12: [Using TypedDict for more precise **kwargs typing](https://docs.python.org/3.12/whatsnew/3.12.html#pep-692-using-typeddict-for-more-precise-kwargs-typing), there may be little support for linters in the current stable version. – Mechanic Pig Apr 30 '23 at 14:27
-1

For fun, I wrote this (I won't use it, and I don't recommend it to others). It should work in some simple situations.

It should be noted that after changing the function signature, the access method of variables in the function body will change (such as bytecode changing from LOAD_GLOBALS to LOAD_FAST or LOAD_DEREF). If we want to achieve true consistency with the literal definition, I believe it is necessary to recompile the function.

The general steps here are to obtain the source code of the function, build and modify the ast, and finally unparse the ast and compile it to obtain a new function object:

import ast
import inspect
import textwrap


def with_signature(sig: inspect.Signature):
    def deco(func):
        source = inspect.getsource(func)
        source = textwrap.dedent(source[source.find('\n') + 1:])
        [fndef] = ast.parse(source).body
        fndef.args = args = ast.arguments([], [], None, [], [], None, [])

        for name, param in sig.parameters.items():
            arg = ast.arg(name)
            if param.kind == inspect.Parameter.POSITIONAL_ONLY:
                args.posonlyargs.append(arg)
                if param.default is not param.empty:
                    args.defaults.append(ast.Constant(param.default))
            elif param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
                args.args.append(arg)
                if param.default is not param.empty:
                    args.defaults.append(ast.Constant(param.default))
            elif param.kind == inspect.Parameter.KEYWORD_ONLY:
                args.kwonlyargs.append(arg)
                args.kw_defaults.append(None if param.default is param.empty
                                        else ast.Constant(param.default))
            elif param.kind == inspect.Parameter.VAR_POSITIONAL:
                if args.vararg is not None:
                    raise SyntaxError
                args.vararg = arg
            elif param.kind == inspect.Parameter.VAR_KEYWORD:
                if args.kwarg is not None:
                    raise SyntaxError
                args.kwarg = arg

        frame = inspect.currentframe().f_back
        locals_ns = frame.f_locals.copy()
        exec(ast.unparse(fndef), frame.f_globals, locals_ns)
        return locals_ns[fndef.name]

    return deco

For example of OP:

if __name__ == '__main__':
    signature = inspect.Signature([
        inspect.Parameter('x', kind=inspect.Parameter.KEYWORD_ONLY, default=None),
        inspect.Parameter('y', kind=inspect.Parameter.KEYWORD_ONLY, default=None),
        inspect.Parameter('z', kind=inspect.Parameter.KEYWORD_ONLY, default=None)
    ])

    @with_signature(signature)
    def fun():
        return [x, y, z]

    print(fun(x=1, y=2, z=3))  # [1, 2, 3]
Mechanic Pig
  • 6,756
  • 3
  • 10
  • 31