##// END OF EJS Templates
ipy_leo: mb completer also works with %
vivainio2 -
Show More
@@ -1,541 +1,541 b''
1 1 """ ILeo - Leo plugin for IPython
2 2
3 3
4 4 """
5 5 import IPython.ipapi
6 6 import IPython.genutils
7 7 import IPython.generics
8 8 from IPython.hooks import CommandChainDispatcher
9 9 import re
10 10 import UserDict
11 11 from IPython.ipapi import TryNext
12 12 import IPython.macro
13 13
14 14 def init_ipython(ipy):
15 15 """ This will be run by _ip.load('ipy_leo')
16 16
17 17 Leo still needs to run update_commander() after this.
18 18
19 19 """
20 20 global ip
21 21 ip = ipy
22 ip.set_hook('complete_command', mb_completer, str_key = 'mb')
22 ip.set_hook('complete_command', mb_completer, str_key = '%mb')
23 23 ip.expose_magic('mb',mb_f)
24 24 ip.expose_magic('lee',lee_f)
25 25 ip.expose_magic('leoref',leoref_f)
26 26 expose_ileo_push(push_cl_node,100)
27 27 # this should be the LAST one that will be executed, and it will never raise TryNext
28 28 expose_ileo_push(push_ipython_script, 1000)
29 29 expose_ileo_push(push_plain_python, 100)
30 30 expose_ileo_push(push_ev_node, 100)
31 31 global wb
32 32 wb = LeoWorkbook()
33 33 ip.user_ns['wb'] = wb
34 34
35 35 show_welcome()
36 36
37 37
38 38 def update_commander(new_leox):
39 39 """ Set the Leo commander to use
40 40
41 41 This will be run every time Leo does ipython-launch; basically,
42 42 when the user switches the document he is focusing on, he should do
43 43 ipython-launch to tell ILeo what document the commands apply to.
44 44
45 45 """
46 46
47 47 global c,g
48 48 c,g = new_leox.c, new_leox.g
49 49 print "Set Leo Commander:",c.frame.getTitle()
50 50
51 51 # will probably be overwritten by user, but handy for experimentation early on
52 52 ip.user_ns['c'] = c
53 53 ip.user_ns['g'] = g
54 54 ip.user_ns['_leo'] = new_leox
55 55
56 56 new_leox.push = push_position_from_leo
57 57 run_leo_startup_node()
58 58
59 59 from IPython.external.simplegeneric import generic
60 60 import pprint
61 61
62 62 def es(s):
63 63 g.es(s, tabName = 'IPython')
64 64 pass
65 65
66 66 @generic
67 67 def format_for_leo(obj):
68 68 """ Convert obj to string representiation (for editing in Leo)"""
69 69 return pprint.pformat(obj)
70 70
71 71 @format_for_leo.when_type(list)
72 72 def format_list(obj):
73 73 return "\n".join(str(s) for s in obj)
74 74
75 75
76 76 attribute_re = re.compile('^[a-zA-Z_][a-zA-Z0-9_]*$')
77 77 def valid_attribute(s):
78 78 return attribute_re.match(s)
79 79
80 80 def all_cells():
81 81 d = {}
82 82 for p in c.allNodes_iter():
83 83 h = p.headString()
84 84 if h.startswith('@a '):
85 85 d[h.lstrip('@a ').strip()] = p.parent().copy()
86 86 elif not valid_attribute(h):
87 87 continue
88 88 d[h] = p.copy()
89 89 return d
90 90
91 91 def eval_node(n):
92 92 body = n.b
93 93 if not body.startswith('@cl'):
94 94 # plain python repr node, just eval it
95 95 return ip.ev(n.b)
96 96 # @cl nodes deserve special treatment - first eval the first line (minus cl), then use it to call the rest of body
97 97 first, rest = body.split('\n',1)
98 98 tup = first.split(None, 1)
99 99 # @cl alone SPECIAL USE-> dump var to user_ns
100 100 if len(tup) == 1:
101 101 val = ip.ev(rest)
102 102 ip.user_ns[n.h] = val
103 103 es("%s = %s" % (n.h, repr(val)[:20] ))
104 104 return val
105 105
106 106 cl, hd = tup
107 107
108 108 xformer = ip.ev(hd.strip())
109 109 es('Transform w/ %s' % repr(xformer))
110 110 return xformer(rest, n)
111 111
112 112 class LeoNode(object, UserDict.DictMixin):
113 113 """ Node in Leo outline
114 114
115 115 Most important attributes (getters/setters available:
116 116 .v - evaluate node, can also be alligned
117 117 .b, .h - body string, headline string
118 118 .l - value as string list
119 119
120 120 Also supports iteration,
121 121
122 122 setitem / getitem (indexing):
123 123 wb.foo['key'] = 12
124 124 assert wb.foo['key'].v == 12
125 125
126 126 Note the asymmetry on setitem and getitem! Also other
127 127 dict methods are available.
128 128
129 129 .ipush() - run push-to-ipython
130 130
131 131 Minibuffer command access (tab completion works):
132 132
133 133 mb save-to-file
134 134
135 135 """
136 136 def __init__(self,p):
137 137 self.p = p.copy()
138 138
139 139 def __str__(self):
140 140 return "<LeoNode %s>" % str(self.p)
141 141
142 142 __repr__ = __str__
143 143
144 144 def __get_h(self): return self.p.headString()
145 145 def __set_h(self,val):
146 146 print "set head",val
147 147 c.beginUpdate()
148 148 try:
149 149 c.setHeadString(self.p,val)
150 150 finally:
151 151 c.endUpdate()
152 152
153 153 h = property( __get_h, __set_h, doc = "Node headline string")
154 154
155 155 def __get_b(self): return self.p.bodyString()
156 156 def __set_b(self,val):
157 157 print "set body",val
158 158 c.beginUpdate()
159 159 try:
160 160 c.setBodyString(self.p, val)
161 161 finally:
162 162 c.endUpdate()
163 163
164 164 b = property(__get_b, __set_b, doc = "Nody body string")
165 165
166 166 def __set_val(self, val):
167 167 self.b = format_for_leo(val)
168 168
169 169 v = property(lambda self: eval_node(self), __set_val, doc = "Node evaluated value")
170 170
171 171 def __set_l(self,val):
172 172 self.b = '\n'.join(val )
173 173 l = property(lambda self : IPython.genutils.SList(self.b.splitlines()),
174 174 __set_l, doc = "Node value as string list")
175 175
176 176 def __iter__(self):
177 177 """ Iterate through nodes direct children """
178 178
179 179 return (LeoNode(p) for p in self.p.children_iter())
180 180
181 181 def __children(self):
182 182 d = {}
183 183 for child in self:
184 184 head = child.h
185 185 tup = head.split(None,1)
186 186 if len(tup) > 1 and tup[0] == '@k':
187 187 d[tup[1]] = child
188 188 continue
189 189
190 190 if not valid_attribute(head):
191 191 d[head] = child
192 192 continue
193 193 return d
194 194 def keys(self):
195 195 d = self.__children()
196 196 return d.keys()
197 197 def __getitem__(self, key):
198 198 """ wb.foo['Some stuff'] Return a child node with headline 'Some stuff'
199 199
200 200 If key is a valid python name (e.g. 'foo'), look for headline '@k foo' as well
201 201 """
202 202 key = str(key)
203 203 d = self.__children()
204 204 return d[key]
205 205 def __setitem__(self, key, val):
206 206 """ You can do wb.foo['My Stuff'] = 12 to create children
207 207
208 208 This will create 'My Stuff' as a child of foo (if it does not exist), and
209 209 do .v = 12 assignment.
210 210
211 211 Exception:
212 212
213 213 wb.foo['bar'] = 12
214 214
215 215 will create a child with headline '@k bar', because bar is a valid python name
216 216 and we don't want to crowd the WorkBook namespace with (possibly numerous) entries
217 217 """
218 218 key = str(key)
219 219 d = self.__children()
220 220 if key in d:
221 221 d[key].v = val
222 222 return
223 223
224 224 if not valid_attribute(key):
225 225 head = key
226 226 else:
227 227 head = '@k ' + key
228 228 p = c.createLastChildNode(self.p, head, '')
229 229 LeoNode(p).v = val
230 230
231 231 def ipush(self):
232 232 """ Does push-to-ipython on the node """
233 233 push_from_leo(self)
234 234
235 235 def go(self):
236 236 """ Set node as current node (to quickly see it in Outline) """
237 237 c.beginUpdate()
238 238 try:
239 239 c.setCurrentPosition(self.p)
240 240 finally:
241 241 c.endUpdate()
242 242
243 243 def script(self):
244 244 """ Method to get the 'tangled' contents of the node
245 245
246 246 (parse @others, << section >> references etc.)
247 247 """
248 248 return g.getScript(c,self.p,useSelectedText=False,useSentinels=False)
249 249
250 250 def __get_uA(self):
251 251 p = self.p
252 252 # Create the uA if necessary.
253 253 if not hasattr(p.v.t,'unknownAttributes'):
254 254 p.v.t.unknownAttributes = {}
255 255
256 256 d = p.v.t.unknownAttributes.setdefault('ipython', {})
257 257 return d
258 258
259 259 uA = property(__get_uA, doc = "Access persistent unknownAttributes of node")
260 260
261 261
262 262 class LeoWorkbook:
263 263 """ class for 'advanced' node access
264 264
265 265 Has attributes for all "discoverable" nodes. Node is discoverable if it
266 266 either
267 267
268 268 - has a valid python name (Foo, bar_12)
269 269 - is a parent of an anchor node (if it has a child '@a foo', it is visible as foo)
270 270
271 271 """
272 272 def __getattr__(self, key):
273 273 if key.startswith('_') or key == 'trait_names' or not valid_attribute(key):
274 274 raise AttributeError
275 275 cells = all_cells()
276 276 p = cells.get(key, None)
277 277 if p is None:
278 278 return add_var(key)
279 279
280 280 return LeoNode(p)
281 281
282 282 def __str__(self):
283 283 return "<LeoWorkbook>"
284 284 def __setattr__(self,key, val):
285 285 raise AttributeError("Direct assignment to workbook denied, try wb.%s.v = %s" % (key,val))
286 286
287 287 __repr__ = __str__
288 288
289 289 def __iter__(self):
290 290 """ Iterate all (even non-exposed) nodes """
291 291 cells = all_cells()
292 292 return (LeoNode(p) for p in c.allNodes_iter())
293 293
294 294 current = property(lambda self: LeoNode(c.currentPosition()), doc = "Currently selected node")
295 295
296 296 def match_h(self, regex):
297 297 cmp = re.compile(regex)
298 298 for node in self:
299 299 if re.match(cmp, node.h, re.IGNORECASE):
300 300 yield node
301 301 return
302 302
303 303 @IPython.generics.complete_object.when_type(LeoWorkbook)
304 304 def workbook_complete(obj, prev):
305 305 return all_cells().keys() + [s for s in prev if not s.startswith('_')]
306 306
307 307
308 308 def add_var(varname):
309 309 c.beginUpdate()
310 310 try:
311 311 p2 = g.findNodeAnywhere(c,varname)
312 312 if p2:
313 313 return LeoNode(p2)
314 314
315 315 rootpos = g.findNodeAnywhere(c,'@ipy-results')
316 316 if rootpos:
317 317 p2 = rootpos.insertAsLastChild()
318 318
319 319 else:
320 320 p2 = c.currentPosition().insertAfter()
321 321
322 322 c.setHeadString(p2,varname)
323 323 return LeoNode(p2)
324 324 finally:
325 325 c.endUpdate()
326 326
327 327 def add_file(self,fname):
328 328 p2 = c.currentPosition().insertAfter()
329 329
330 330 push_from_leo = CommandChainDispatcher()
331 331
332 332 def expose_ileo_push(f, prio = 0):
333 333 push_from_leo.add(f, prio)
334 334
335 335 def push_ipython_script(node):
336 336 """ Execute the node body in IPython, as if it was entered in interactive prompt """
337 337 c.beginUpdate()
338 338 try:
339 339 ohist = ip.IP.output_hist
340 340 hstart = len(ip.IP.input_hist)
341 341 script = node.script()
342 342
343 343 script = g.splitLines(script + '\n')
344 344 ip.user_ns['_p'] = node
345 345 ip.runlines(script)
346 346 del ip.user_ns['_p']
347 347
348 348 has_output = False
349 349 for idx in range(hstart,len(ip.IP.input_hist)):
350 350 val = ohist.get(idx,None)
351 351 if val is None:
352 352 continue
353 353 has_output = True
354 354 inp = ip.IP.input_hist[idx]
355 355 if inp.strip():
356 356 es('In: %s' % (inp[:40], ))
357 357
358 358 es('<%d> %s' % (idx, pprint.pformat(ohist[idx],width = 40)))
359 359
360 360 if not has_output:
361 361 es('ipy run: %s (%d LL)' %( node.h,len(script)))
362 362 finally:
363 363 c.endUpdate()
364 364
365 365
366 366 def eval_body(body):
367 367 try:
368 368 val = ip.ev(body)
369 369 except:
370 370 # just use stringlist if it's not completely legal python expression
371 371 val = IPython.genutils.SList(body.splitlines())
372 372 return val
373 373
374 374 def push_plain_python(node):
375 375 if not node.h.endswith('P'):
376 376 raise TryNext
377 377 script = node.script()
378 378 lines = script.count('\n')
379 379 try:
380 380 exec script in ip.user_ns
381 381 except:
382 382 print " -- Exception in script:\n"+script + "\n --"
383 383 raise
384 384 es('ipy plain: %s (%d LL)' % (node.h,lines))
385 385
386 386
387 387 def push_cl_node(node):
388 388 """ If node starts with @cl, eval it
389 389
390 390 The result is put to root @ipy-results node
391 391 """
392 392 if not node.b.startswith('@cl'):
393 393 raise TryNext
394 394
395 395 p2 = g.findNodeAnywhere(c,'@ipy-results')
396 396 val = node.v
397 397 if p2:
398 398 es("=> @ipy-results")
399 399 LeoNode(p2).v = val
400 400 es(val)
401 401
402 402 def push_ev_node(node):
403 403 """ If headline starts with @ev, eval it and put result in body """
404 404 if not node.h.startswith('@ev '):
405 405 raise TryNext
406 406 expr = node.h.lstrip('@ev ')
407 407 es('ipy eval ' + expr)
408 408 res = ip.ev(expr)
409 409 node.v = res
410 410
411 411
412 412 def push_position_from_leo(p):
413 413 push_from_leo(LeoNode(p))
414 414
415 415 @generic
416 416 def edit_object_in_leo(obj, varname):
417 417 """ Make it @cl node so it can be pushed back directly by alt+I """
418 418 node = add_var(varname)
419 419 formatted = format_for_leo(obj)
420 420 if not formatted.startswith('@cl'):
421 421 formatted = '@cl\n' + formatted
422 422 node.b = formatted
423 423 node.go()
424 424
425 425 @edit_object_in_leo.when_type(IPython.macro.Macro)
426 426 def edit_macro(obj,varname):
427 427 bod = '_ip.defmacro("""\\\n' + obj.value + '""")'
428 428 node = add_var('Macro_' + varname)
429 429 node.b = bod
430 430 node.go()
431 431
432 432 def get_history(hstart = 0):
433 433 res = []
434 434 ohist = ip.IP.output_hist
435 435
436 436 for idx in range(hstart, len(ip.IP.input_hist)):
437 437 val = ohist.get(idx,None)
438 438 has_output = True
439 439 inp = ip.IP.input_hist_raw[idx]
440 440 if inp.strip():
441 441 res.append('In [%d]: %s' % (idx, inp))
442 442 if val:
443 443 res.append(pprint.pformat(val))
444 444 res.append('\n')
445 445 return ''.join(res)
446 446
447 447
448 448 def lee_f(self,s):
449 449 """ Open file(s)/objects in Leo
450 450
451 451 - %lee hist -> open full session history in leo
452 452 - Takes an object
453 453 - Takes an mglob pattern, e.g. '%lee *.cpp' or %leo 'rec:*.cpp'
454 454 """
455 455 import os
456 456
457 457 c.beginUpdate()
458 458 try:
459 459 if s == 'hist':
460 460 wb.ipython_history.b = get_history()
461 461 wb.ipython_history.go()
462 462 return
463 463
464 464
465 465
466 466 # try editing the object directly
467 467 obj = ip.user_ns.get(s, None)
468 468 if obj is not None:
469 469 edit_object_in_leo(obj,s)
470 470 return
471 471
472 472 # if it's not object, it's a file name / mglob pattern
473 473 from IPython.external import mglob
474 474
475 475 files = (os.path.abspath(f) for f in mglob.expand(s))
476 476 for fname in files:
477 477 p = g.findNodeAnywhere(c,'@auto ' + fname)
478 478 if not p:
479 479 p = c.currentPosition().insertAfter()
480 480
481 481 p.setHeadString('@auto ' + fname)
482 482 if os.path.isfile(fname):
483 483 c.setBodyString(p,open(fname).read())
484 484 c.selectPosition(p)
485 485 print "Editing file(s), press ctrl+shift+w in Leo to write @auto nodes"
486 486 finally:
487 487 c.endUpdate()
488 488
489 489
490 490
491 491 def leoref_f(self,s):
492 492 """ Quick reference for ILeo """
493 493 import textwrap
494 494 print textwrap.dedent("""\
495 495 %leoe file/object - open file / object in leo
496 496 wb.foo.v - eval node foo (i.e. headstring is 'foo' or '@ipy foo')
497 497 wb.foo.v = 12 - assign to body of node foo
498 498 wb.foo.b - read or write the body of node foo
499 499 wb.foo.l - body of node foo as string list
500 500
501 501 for el in wb.foo:
502 502 print el.v
503 503
504 504 """
505 505 )
506 506
507 507
508 508
509 509 def mb_f(self, arg):
510 510 """ Execute leo minibuffer commands
511 511
512 512 Example:
513 513 mb save-to-file
514 514 """
515 515 c.executeMinibufferCommand(arg)
516 516
517 517 def mb_completer(self,event):
518 518 """ Custom completer for minibuffer """
519 519 cmd_param = event.line.split()
520 520 if event.line.endswith(' '):
521 521 cmd_param.append('')
522 522 if len(cmd_param) > 2:
523 523 return ip.IP.Completer.file_matches(event.symbol)
524 524 cmds = c.commandsDict.keys()
525 525 cmds.sort()
526 526 return cmds
527 527
528 528 def show_welcome():
529 529 print "------------------"
530 530 print "Welcome to Leo-enabled IPython session!"
531 531 print "Try %leoref for quick reference."
532 532 import IPython.platutils
533 533 IPython.platutils.set_term_title('ILeo')
534 534 IPython.platutils.freeze_term_title()
535 535
536 536 def run_leo_startup_node():
537 537 p = g.findNodeAnywhere(c,'@ipy-startup')
538 538 if p:
539 539 print "Running @ipy-startup nodes"
540 540 for n in LeoNode(p):
541 541 push_from_leo(n)
@@ -1,174 +1,175 b''
1 1 """ Preliminary "job control" extensions for IPython
2 2
3 3 requires python 2.4 (or separate 'subprocess' module
4 4
5 5 This provides 2 features, launching background jobs and killing foreground jobs from another IPython instance.
6 6
7 7 Launching background jobs:
8 8
9 9 Usage:
10 10
11 11 [ipython]|2> import jobctrl
12 12 [ipython]|3> &ls
13 13 <3> <jobctrl.IpyPopen object at 0x00D87FD0>
14 14 [ipython]|4> _3.go
15 15 -----------> _3.go()
16 16 ChangeLog
17 17 IPython
18 18 MANIFEST.in
19 19 README
20 20 README_Windows.txt
21 21
22 22 ...
23 23
24 24 Killing foreground tasks:
25 25
26 26 Launch IPython instance, run a blocking command:
27 27
28 28 [Q:/ipython]|1> import jobctrl
29 29 [Q:/ipython]|2> cat
30 30
31 31 Now launch a new IPython prompt and kill the process:
32 32
33 33 IPython 0.8.3.svn.r2919 [on Py 2.5]
34 34 [Q:/ipython]|1> import jobctrl
35 35 [Q:/ipython]|2> %tasks
36 36 6020: 'cat ' (Q:\ipython)
37 37 [Q:/ipython]|3> %kill
38 38 SUCCESS: The process with PID 6020 has been terminated.
39 39 [Q:/ipython]|4>
40 40
41 41 (you don't need to specify PID for %kill if only one task is running)
42 42 """
43 43
44 44 from subprocess import Popen,PIPE
45 45 import os,shlex,sys,time
46 46
47 47 from IPython import genutils
48 48
49 49 import IPython.ipapi
50 50
51 51 if os.name == 'nt':
52 52 def kill_process(pid):
53 53 os.system('taskkill /F /PID %d' % pid)
54 54 else:
55 55 def kill_process(pid):
56 56 os.system('kill -9 %d' % pid)
57 57
58 58
59 59
60 60 class IpyPopen(Popen):
61 61 def go(self):
62 62 print self.communicate()[0]
63 63 def __repr__(self):
64 64 return '<IPython job "%s" PID=%d>' % (self.line, self.pid)
65 65
66 66 def kill(self):
67 67 kill_process(self.pid)
68 68
69 69 def startjob(job):
70 70 p = IpyPopen(shlex.split(job), stdout=PIPE, shell = False)
71 71 p.line = job
72 72 return p
73 73
74 74 def jobctrl_prefilter_f(self,line):
75 75 if line.startswith('&'):
76 76 pre,fn,rest = self.split_user_input(line[1:])
77 77
78 78 line = ip.IP.expand_aliases(fn,rest)
79 79 return '_ip.startjob(%s)' % genutils.make_quoted_expr(line)
80 80
81 81 raise IPython.ipapi.TryNext
82 82
83 83
84 84 def job_list(ip):
85 85 keys = ip.db.keys('tasks/*')
86 86 ents = [ip.db[k] for k in keys]
87 87 return ents
88 88
89 89 def magic_tasks(self,line):
90 90 """ Show a list of tasks.
91 91
92 92 A 'task' is a process that has been started in IPython when 'jobctrl' extension is enabled.
93 93 Tasks can be killed with %kill.
94 94 """
95 95 ip = self.getapi()
96 96 ents = job_list(ip)
97 97 if not ents:
98 98 print "No tasks running"
99 99 for pid,cmd,cwd,t in ents:
100 100 dur = int(time.time()-t)
101 101 print "%d: '%s' (%s) %d:%02d" % (pid,cmd,cwd, dur / 60,dur%60)
102 102
103 103 def magic_kill(self,line):
104 104 """ Kill a task
105 105
106 106 Without args, either kill one task (if only one running) or show list (if many)
107 107 With arg, assume it's the process id.
108 108
109 109 %kill is typically (much) more powerful than trying to terminate a process with ctrl+C.
110 110 """
111 111 ip = self.getapi()
112 112 jobs = job_list(ip)
113 113
114 114 if not line.strip():
115 115 if len(jobs) == 1:
116 116 kill_process(jobs[0][0])
117 117 else:
118 118 magic_tasks(self,line)
119 119 return
120 120
121 121 try:
122 122 pid = int(line)
123 123 kill_process(pid)
124 124 except ValueError:
125 125 magic_tasks(self,line)
126 126
127 127 if sys.platform == 'win32':
128 128 shell_internal_commands = 'break chcp cls copy ctty date del erase dir md mkdir path prompt rd rmdir time type ver vol'.split()
129 129 else:
130 130 # todo linux commands
131 131 shell_internal_commands = []
132 132
133 133
134 134 def jobctrl_shellcmd(ip,cmd):
135 135 """ os.system replacement that stores process info to db['tasks/t1234'] """
136 cmd = cmd.strip()
136 137 cmdname = cmd.split(None,1)[0]
137 138 if cmdname in shell_internal_commands:
138 139 use_shell = True
139 140 else:
140 141 use_shell = False
141 142
142 143 jobentry = None
143 144 try:
144 145 try:
145 146 p = Popen(cmd,shell = use_shell)
146 147 except WindowsError:
147 148 if use_shell:
148 149 # try with os.system
149 150 os.system(cmd)
150 151 return
151 152 else:
152 153 # have to go via shell, sucks
153 154 p = Popen(cmd,shell = True)
154 155
155 156 jobentry = 'tasks/t' + str(p.pid)
156 157 ip.db[jobentry] = (p.pid,cmd,os.getcwd(),time.time())
157 158 p.communicate()
158 159
159 160 finally:
160 161 if jobentry:
161 162 del ip.db[jobentry]
162 163
163 164
164 165 def install():
165 166 global ip
166 167 ip = IPython.ipapi.get()
167 168 # needed to make startjob visible as _ip.startjob('blah')
168 169 ip.startjob = startjob
169 170 ip.set_hook('input_prefilter', jobctrl_prefilter_f)
170 171 ip.set_hook('shell_hook', jobctrl_shellcmd)
171 172 ip.expose_magic('kill',magic_kill)
172 173 ip.expose_magic('tasks',magic_tasks)
173 174
174 175 install()
General Comments 0
You need to be logged in to leave comments. Login now