1"""
2This module contains utility function and classes to inject simple ast
3transformations based on code strings into IPython. While it is already possible
4with ast-transformers it is not easy to directly manipulate ast.
5
6
7IPython has pre-code and post-code hooks, but are ran from within the IPython
8machinery so may be inappropriate, for example for performance measurement.
9
10This module give you tools to simplify this, and expose 2 classes:
11
12- `ReplaceCodeTransformer` which is a simple ast transformer based on code
13 template,
14
15and for advance case:
16
17- `Mangler` which is a simple ast transformer that mangle names in the ast.
18
19
20Example, let's try to make a simple version of the ``timeit`` magic, that run a
21code snippet 10 times and print the average time taken.
22
23Basically we want to run :
24
25.. code-block:: python
26
27 from time import perf_counter
28 now = perf_counter()
29 for i in range(10):
30 __code__ # our code
31 print(f"Time taken: {(perf_counter() - now)/10}")
32 __ret__ # the result of the last statement
33
34Where ``__code__`` is the code snippet we want to run, and ``__ret__`` is the
35result, so that if we for example run `dataframe.head()` IPython still display
36the head of dataframe instead of nothing.
37
38Here is a complete example of a file `timit2.py` that define such a magic:
39
40.. code-block:: python
41
42 from IPython.core.magic import (
43 Magics,
44 magics_class,
45 line_cell_magic,
46 )
47 from IPython.core.magics.ast_mod import ReplaceCodeTransformer
48 from textwrap import dedent
49 import ast
50
51 template = template = dedent('''
52 from time import perf_counter
53 now = perf_counter()
54 for i in range(10):
55 __code__
56 print(f"Time taken: {(perf_counter() - now)/10}")
57 __ret__
58 '''
59 )
60
61
62 @magics_class
63 class AstM(Magics):
64 @line_cell_magic
65 def t2(self, line, cell):
66 transformer = ReplaceCodeTransformer.from_string(template)
67 transformer.debug = True
68 transformer.mangler.debug = True
69 new_code = transformer.visit(ast.parse(cell))
70 return exec(compile(new_code, "<ast>", "exec"))
71
72
73 def load_ipython_extension(ip):
74 ip.register_magics(AstM)
75
76
77
78.. code-block:: python
79
80 In [1]: %load_ext timit2
81
82 In [2]: %%t2
83 ...: import time
84 ...: time.sleep(0.05)
85 ...:
86 ...:
87 Time taken: 0.05435649999999441
88
89
90If you wish to ran all the code enter in IPython in an ast transformer, you can
91do so as well:
92
93.. code-block:: python
94
95 In [1]: from IPython.core.magics.ast_mod import ReplaceCodeTransformer
96 ...:
97 ...: template = '''
98 ...: from time import perf_counter
99 ...: now = perf_counter()
100 ...: __code__
101 ...: print(f"Code ran in {perf_counter()-now}")
102 ...: __ret__'''
103 ...:
104 ...: get_ipython().ast_transformers.append(ReplaceCodeTransformer.from_string(template))
105
106 In [2]: 1+1
107 Code ran in 3.40410006174352e-05
108 Out[2]: 2
109
110
111
112Hygiene and Mangling
113--------------------
114
115The ast transformer above is not hygienic, it may not work if the user code use
116the same variable names as the ones used in the template. For example.
117
118To help with this by default the `ReplaceCodeTransformer` will mangle all names
119staring with 3 underscores. This is a simple heuristic that should work in most
120case, but can be cumbersome in some case. We provide a `Mangler` class that can
121be overridden to change the mangling heuristic, or simply use the `mangle_all`
122utility function. It will _try_ to mangle all names (except `__ret__` and
123`__code__`), but this include builtins (``print``, ``range``, ``type``) and
124replace those by invalid identifiers py prepending ``mangle-``:
125``mangle-print``, ``mangle-range``, ``mangle-type`` etc. This is not a problem
126as currently Python AST support invalid identifiers, but it may not be the case
127in the future.
128
129You can set `ReplaceCodeTransformer.debug=True` and
130`ReplaceCodeTransformer.mangler.debug=True` to see the code after mangling and
131transforming:
132
133.. code-block:: python
134
135
136 In [1]: from IPython.core.magics.ast_mod import ReplaceCodeTransformer, mangle_all
137 ...:
138 ...: template = '''
139 ...: from builtins import type, print
140 ...: from time import perf_counter
141 ...: now = perf_counter()
142 ...: __code__
143 ...: print(f"Code ran in {perf_counter()-now}")
144 ...: __ret__'''
145 ...:
146 ...: transformer = ReplaceCodeTransformer.from_string(template, mangling_predicate=mangle_all)
147
148
149 In [2]: transformer.debug = True
150 ...: transformer.mangler.debug = True
151 ...: get_ipython().ast_transformers.append(transformer)
152
153 In [3]: 1+1
154 Mangling Alias mangle-type
155 Mangling Alias mangle-print
156 Mangling Alias mangle-perf_counter
157 Mangling now
158 Mangling perf_counter
159 Not mangling __code__
160 Mangling print
161 Mangling perf_counter
162 Mangling now
163 Not mangling __ret__
164 ---- Transformed code ----
165 from builtins import type as mangle-type, print as mangle-print
166 from time import perf_counter as mangle-perf_counter
167 mangle-now = mangle-perf_counter()
168 ret-tmp = 1 + 1
169 mangle-print(f'Code ran in {mangle-perf_counter() - mangle-now}')
170 ret-tmp
171 ---- ---------------- ----
172 Code ran in 0.00013654199938173406
173 Out[3]: 2
174
175
176"""
177
178__skip_doctest__ = True
179
180
181from ast import (
182 NodeTransformer,
183 Store,
184 Load,
185 Name,
186 Expr,
187 Assign,
188 Module,
189 Import,
190 ImportFrom,
191)
192import ast
193import copy
194
195
196mangle_all = lambda name: name not in ("__ret__", "__code__")
197
198
199class Mangler(NodeTransformer):
200 """
201 Mangle given names in and ast tree to make sure they do not conflict with
202 user code.
203 """
204
205 enabled: bool = True
206 debug: bool = False
207
208 def log(self, *args, **kwargs):
209 if self.debug:
210 print(*args, **kwargs)
211
212 def __init__(self, predicate=None):
213 if predicate is None:
214 predicate = lambda name: name.startswith("___")
215 self.predicate = predicate
216
217 def visit_Name(self, node):
218 if self.predicate(node.id):
219 self.log("Mangling", node.id)
220 # Once in the ast we do not need
221 # names to be valid identifiers.
222 node.id = "mangle-" + node.id
223 else:
224 self.log("Not mangling", node.id)
225 return node
226
227 def visit_FunctionDef(self, node):
228 if self.predicate(node.name):
229 self.log("Mangling", node.name)
230 node.name = "mangle-" + node.name
231 else:
232 self.log("Not mangling", node.name)
233
234 for arg in node.args.args:
235 if self.predicate(arg.arg):
236 self.log("Mangling function arg", arg.arg)
237 arg.arg = "mangle-" + arg.arg
238 else:
239 self.log("Not mangling function arg", arg.arg)
240 return self.generic_visit(node)
241
242 def visit_ImportFrom(self, node: ImportFrom):
243 return self._visit_Import_and_ImportFrom(node)
244
245 def visit_Import(self, node: Import):
246 return self._visit_Import_and_ImportFrom(node)
247
248 def _visit_Import_and_ImportFrom(self, node: Import | ImportFrom):
249 for alias in node.names:
250 asname = alias.name if alias.asname is None else alias.asname
251 if self.predicate(asname):
252 new_name: str = "mangle-" + asname
253 self.log("Mangling Alias", new_name)
254 alias.asname = new_name
255 else:
256 self.log("Not mangling Alias", alias.asname)
257 return node
258
259
260class ReplaceCodeTransformer(NodeTransformer):
261 enabled: bool = True
262 debug: bool = False
263 mangler: Mangler
264
265 def __init__(
266 self, template: Module, mapping: dict | None = None, mangling_predicate=None
267 ):
268 assert isinstance(mapping, (dict, type(None)))
269 assert isinstance(mangling_predicate, (type(None), type(lambda: None)))
270 assert isinstance(template, ast.Module)
271 self.template = template
272 self.mangler = Mangler(predicate=mangling_predicate)
273 if mapping is None:
274 mapping = {}
275 self.mapping = mapping
276
277 @classmethod
278 def from_string(
279 cls, template: str, mapping: dict | None = None, mangling_predicate=None
280 ):
281 return cls(
282 ast.parse(template), mapping=mapping, mangling_predicate=mangling_predicate
283 )
284
285 def visit_Module(self, code):
286 if not self.enabled:
287 return code
288 # if not isinstance(code, ast.Module):
289 # recursively called...
290 # return generic_visit(self, code)
291 last = code.body[-1]
292 if isinstance(last, Expr):
293 code.body.pop()
294 code.body.append(Assign([Name("ret-tmp", ctx=Store())], value=last.value))
295 ast.fix_missing_locations(code)
296 ret = Expr(value=Name("ret-tmp", ctx=Load()))
297 ret = ast.fix_missing_locations(ret)
298 self.mapping["__ret__"] = ret
299 else:
300 self.mapping["__ret__"] = ast.parse("None").body[0]
301 self.mapping["__code__"] = code.body
302 tpl = ast.fix_missing_locations(self.template)
303
304 tx = copy.deepcopy(tpl)
305 tx = self.mangler.visit(tx)
306 node = self.generic_visit(tx)
307 node_2 = ast.fix_missing_locations(node)
308 if self.debug:
309 print("---- Transformed code ----")
310 print(ast.unparse(node_2))
311 print("---- ---------------- ----")
312 return node_2
313
314 # this does not work as the name might be in a list and one might want to extend the list.
315 # def visit_Name(self, name):
316 # if name.id in self.mapping and name.id == "__ret__":
317 # print(name, "in mapping")
318 # if isinstance(name.ctx, ast.Store):
319 # return Name("tmp", ctx=Store())
320 # else:
321 # return copy.deepcopy(self.mapping[name.id])
322 # return name
323
324 def visit_Expr(self, expr):
325 if isinstance(expr.value, Name) and expr.value.id in self.mapping:
326 if self.mapping[expr.value.id] is not None:
327 return copy.deepcopy(self.mapping[expr.value.id])
328 return self.generic_visit(expr)