1# -*- coding: utf-8 -*-
2# Licensed under a 3-clause BSD style license - see LICENSE.rst
3"""Utilities for generating new Python code at runtime."""
4
5
6import inspect
7import itertools
8import keyword
9import os
10import re
11import textwrap
12
13from .introspection import find_current_module
14
15
16__all__ = ['make_function_with_signature']
17
18
19_ARGNAME_RE = re.compile(r'^[A-Za-z][A-Za-z_]*')
20"""
21Regular expression used my make_func which limits the allowed argument
22names for the created function.  Only valid Python variable names in
23the ASCII range and not beginning with '_' are allowed, currently.
24"""
25
26
27def make_function_with_signature(func, args=(), kwargs={}, varargs=None,
28                                 varkwargs=None, name=None):
29    """
30    Make a new function from an existing function but with the desired
31    signature.
32
33    The desired signature must of course be compatible with the arguments
34    actually accepted by the input function.
35
36    The ``args`` are strings that should be the names of the positional
37    arguments.  ``kwargs`` can map names of keyword arguments to their
38    default values.  It may be either a ``dict`` or a list of ``(keyword,
39    default)`` tuples.
40
41    If ``varargs`` is a string it is added to the positional arguments as
42    ``*<varargs>``.  Likewise ``varkwargs`` can be the name for a variable
43    keyword argument placeholder like ``**<varkwargs>``.
44
45    If not specified the name of the new function is taken from the original
46    function.  Otherwise, the ``name`` argument can be used to specify a new
47    name.
48
49    Note, the names may only be valid Python variable names.
50    """
51
52    pos_args = []
53    key_args = []
54
55    if isinstance(kwargs, dict):
56        iter_kwargs = kwargs.items()
57    else:
58        iter_kwargs = iter(kwargs)
59
60    # Check that all the argument names are valid
61    for item in itertools.chain(args, iter_kwargs):
62        if isinstance(item, tuple):
63            argname = item[0]
64            key_args.append(item)
65        else:
66            argname = item
67            pos_args.append(item)
68
69        if keyword.iskeyword(argname) or not _ARGNAME_RE.match(argname):
70            raise SyntaxError(f'invalid argument name: {argname}')
71
72    for item in (varargs, varkwargs):
73        if item is not None:
74            if keyword.iskeyword(item) or not _ARGNAME_RE.match(item):
75                raise SyntaxError(f'invalid argument name: {item}')
76
77    def_signature = [', '.join(pos_args)]
78
79    if varargs:
80        def_signature.append(f', *{varargs}')
81
82    call_signature = def_signature[:]
83
84    if name is None:
85        name = func.__name__
86
87    global_vars = {f'__{name}__func': func}
88    local_vars = {}
89    # Make local variables to handle setting the default args
90    for idx, item in enumerate(key_args):
91        key, value = item
92        default_var = f'_kwargs{idx}'
93        local_vars[default_var] = value
94        def_signature.append(f', {key}={default_var}')
95        call_signature.append(', {0}={0}'.format(key))
96
97    if varkwargs:
98        def_signature.append(f', **{varkwargs}')
99        call_signature.append(f', **{varkwargs}')
100
101    def_signature = ''.join(def_signature).lstrip(', ')
102    call_signature = ''.join(call_signature).lstrip(', ')
103
104    mod = find_current_module(2)
105    frm = inspect.currentframe().f_back
106
107    if mod:
108        filename = mod.__file__
109        modname = mod.__name__
110        if filename.endswith('.pyc'):
111            filename = os.path.splitext(filename)[0] + '.py'
112    else:
113        filename = '<string>'
114        modname = '__main__'
115
116    # Subtract 2 from the line number since the length of the template itself
117    # is two lines.  Therefore we have to subtract those off in order for the
118    # pointer in tracebacks from __{name}__func to point to the right spot.
119    lineno = frm.f_lineno - 2
120
121    # The lstrip is in case there were *no* positional arguments (a rare case)
122    # in any context this will actually be used...
123    template = textwrap.dedent("""{0}\
124    def {name}({sig1}):
125        return __{name}__func({sig2})
126    """.format('\n' * lineno, name=name, sig1=def_signature,
127               sig2=call_signature))
128
129    code = compile(template, filename, 'single')
130
131    eval(code, global_vars, local_vars)
132
133    new_func = local_vars[name]
134    new_func.__module__ = modname
135    new_func.__doc__ = func.__doc__
136
137    return new_func
138