-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathfunction_factory.py
288 lines (237 loc) · 9.12 KB
/
function_factory.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""
ORIGINALLY from https://gist.github.com/dhagrow/d3414e3c6ae25dfa606238355aea2ca5
Python is a dynamic language, and it is relatively easy to dynamically create
and modify things such as classes and objects. Functions, however, are quite
challenging to create dynamicallcallback.
One area where we might want to do this is in an RPC library, where a function
defined on a server needs to be available remotely on a client.
The naive solution is to simply pass arguments to a generic function that
accepts `*args` and `**kwargs`. A lot of information is lost with this approach,
however, in particular the number of arguments taken. Used in an RPC
implementation, this also delays any error feedback until after the arguments
have reached the server.
If you search online, most practical solutions involve `exec()`. This is
generally the approach chosen by many Python RPC libraries. This is, of course,
a very insecure solution, one that opens any program up to malicious code
execution.
This experiment creates a real function at the highest layer available: the AST.
There are several challenges to this approach. The most significant is that on
the AST layer, function arguments must be defined according to their type. This
greatly limits the flexibility allowed when defining a function with Python
syntax.
This experiment has a few requirements that introduce (and relieve) additional
challenges:
- Must return a representative function signature to the Python interpreter
- Must support both Python 2 and 3
- Must allow serialization to JSON and/or MsgPack
"""
from __future__ import print_function
import ast
import collections
import numbers
import sys
import types
PY3 = sys.version_info.major >= 3
def create_function_2(name, args, callback):
y_code = types.CodeType(
args,
callback.func_code.co_nlocals,
callback.func_code.co_stacksize,
callback.func_code.co_flags,
callback.func_code.co_code,
callback.func_code.co_consts,
callback.func_code.co_names,
callback.func_code.co_varnames,
callback.func_code.co_filename,
name,
callback.func_code.co_firstlineno,
callback.func_code.co_lnotab,
)
return types.FunctionType(y_code, callback.func_globals, name)
def create_function(name, signature, callback):
"""Dynamically creates a function that wraps a call to *callback*, based
on the provided *signature*.
Note that only default arguments with a value of `None` are supported. Any
other value will raise a `TypeError`.
"""
# utils to set default values when creating a ast objects
Loc = lambda cls, **kw: cls(annotation=None, lineno=1, col_offset=0, **kw)
Name = lambda id, ctx=None: Loc(ast.Name, id=id, ctx=ctx or ast.Load())
# vars for the callback call
call_args = []
call_keywords = [] # PY3
call_starargs = None # PY2
call_kwargs = None # PY2
# vars for the generated function signature
func_args = []
func_defaults = []
vararg = None
kwarg = None
# vars for the args with default values
defaults = []
# assign args based on *signature*
for param in viewvalues(signature.parameters):
if param.default is not param.empty:
if isinstance(param.default, type(None)):
# `ast.NameConstant` is used in PY3, but both support `ast.Name`
func_defaults.append(Name("None"))
elif isinstance(param.default, bool):
# `ast.NameConstant` is used in PY3, but both support `ast.Name`
typ = str if PY3 else bytes
func_defaults.append(Name(typ(param.default)))
elif isinstance(param.default, numbers.Number):
func_defaults.append(Loc(ast.Num, n=param.default))
elif isinstance(param.default, str):
func_defaults.append(Loc(ast.Str, s=param.default))
elif isinstance(param.default, bytes):
typ = ast.Bytes if PY3 else ast.Str
func_defaults.append(Loc(typ, s=param.default))
elif isinstance(param.default, list):
func_defaults.append(Loc(ast.List, elts=param.default, ctx=ast.Load()))
elif isinstance(param.default, tuple):
func_defaults.append(
Loc(ast.Tuple, elts=list(param.default), ctx=ast.Load())
)
elif isinstance(param.default, dict):
func_defaults.append(
Loc(
ast.Dict,
keys=list(viewkeys(param.default)),
values=list(viewvalues(param.default)),
)
)
else:
err = "unsupported default argument type: {}"
raise TypeError(err.format(type(param.default)))
defaults.append(param.default)
# func_defaults.append(Name('None'))
# defaults.append(None)
if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}:
call_args.append(Name(param.name))
if PY3:
func_args.append(Loc(ast.arg, arg=param.name))
else:
func_args.append(Name(param.name, ast.Param()))
elif param.kind == param.VAR_POSITIONAL:
if PY3:
call_args.append(
Loc(ast.Starred, value=Name(param.name), ctx=ast.Load())
)
vararg = Loc(ast.arg, arg=param.name)
else:
call_starargs = Name(param.name)
vararg = param.name
elif param.kind == param.KEYWORD_ONLY:
err = "TODO: KEYWORD_ONLY param support, param: {}"
raise TypeError(err.format(param.name))
elif param.kind == param.VAR_KEYWORD:
if PY3:
call_keywords.append(Loc(ast.keyword, arg=None, value=Name(param.name)))
kwarg = Loc(ast.arg, arg=param.name)
else:
call_kwargs = Name(param.name)
kwarg = param.name
# generate the ast for the *callback* call
call_ast = Loc(
ast.Call,
func=Name(callback.__name__),
args=call_args,
keywords=call_keywords,
starargs=call_starargs,
kwargs=call_kwargs,
)
# generate the function ast
func_ast = Loc(
ast.FunctionDef,
name=to_func_name(name),
args=ast.arguments(
args=func_args,
vararg=vararg,
defaults=func_defaults,
kwarg=kwarg,
kwonlyargs=[],
kw_defaults=[],
),
body=[Loc(ast.Return, value=call_ast)],
decorator_list=[],
returns=None,
)
# compile the ast and get the function code
mod_ast = ast.Module(body=[func_ast])
module_code = compile(mod_ast, "<generated-ast>", "exec")
func_code = [c for c in module_code.co_consts if isinstance(c, types.CodeType)][0]
# return the generated function
return types.FunctionType(
func_code, {callback.__name__: callback}, argdefs=tuple(defaults)
)
##
## support functions
##
def viewitems(obj):
return getattr(obj, "viewitems", obj.items)()
def viewkeys(obj):
return getattr(obj, "viewkeys", obj.keys)()
def viewvalues(obj):
return getattr(obj, "viewvalues", obj.values)()
def to_func_name(name):
# func.__name__ must be bytes in Python2
return to_unicode(name) if PY3 else to_bytes(name)
def to_bytes(s, encoding="utf8"):
if isinstance(s, bytes):
pass
elif isinstance(s, str):
s = s.encode(encoding)
return s
def to_unicode(s, encoding="utf8"):
if isinstance(s, bytes):
s = s.decode(encoding)
elif isinstance(s, str):
pass
elif isinstance(s, dict):
s = {to_unicode(k): to_unicode(v) for k, v in viewitems(s)}
elif isinstance(s, collections.Iterable):
s = [to_unicode(x, encoding) for x in s]
return s
##
## demo
##
# TODO: keep annotations ???
# or at least metadata ???
def main():
if PY3:
from inspect import signature
else:
from funcsigs import signature
# TODO: metadata ( pulled from API? ) -> signature
# TODO: create_function with same execution
#
# original function
def original(a, b, *args, **kwargs):
# TODO:
# docker run
# bind a,b,.... to CLI of run ?
# capture outputs
# returns serialized or pointers ...???
#
return a, b, args, kwargs
sig = signature(original)
print("original:", original)
print("original signature:", sig)
print("original ret:", original(1, 2, 4, borp="torp"))
# cloned function
def callback(*args, **kwargs):
return args, kwargs
cloned = create_function("clone", sig, callback)
sig = signature(cloned)
print("cloned:", cloned)
print("cloned signature:", sig)
print("cloned ret:", cloned(1, 2, 4, borp="torp"))
def main2():
myfunc = create_function_2("myfunc", 3)
print(repr(myfunc))
print(myfunc.func_name)
print(myfunc.func_code.co_argcount)
myfunc(1, 2, 3, 4)
# TypeError: myfunc() takes exactly 3 arguments (4 given)
if __name__ == "__main__":
main()