##// END OF EJS Templates
oops... old habits die hard.
Barry Wark -
Show More
@@ -1,429 +1,429 b''
1 # encoding: utf-8
1 # encoding: utf-8
2 # -*- test-case-name: IPython.frontend.cocoa.tests.test_cocoa_frontend -*-
2 # -*- test-case-name: IPython.frontend.cocoa.tests.test_cocoa_frontend -*-
3
3
4 """PyObjC classes to provide a Cocoa frontend to the
4 """PyObjC classes to provide a Cocoa frontend to the
5 IPython.kernel.engineservice.IEngineBase.
5 IPython.kernel.engineservice.IEngineBase.
6
6
7 To add an IPython interpreter to a cocoa app, instantiate an
7 To add an IPython interpreter to a cocoa app, instantiate an
8 IPythonCocoaController in a XIB and connect its textView outlet to an
8 IPythonCocoaController in a XIB and connect its textView outlet to an
9 NSTextView instance in your UI. That's it.
9 NSTextView instance in your UI. That's it.
10
10
11 Author: Barry Wark
11 Author: Barry Wark
12 """
12 """
13
13
14 __docformat__ = "restructuredtext en"
14 __docformat__ = "restructuredtext en"
15
15
16 #-----------------------------------------------------------------------------
16 #-----------------------------------------------------------------------------
17 # Copyright (C) 2008 The IPython Development Team
17 # Copyright (C) 2008 The IPython Development Team
18 #
18 #
19 # Distributed under the terms of the BSD License. The full license is in
19 # Distributed under the terms of the BSD License. The full license is in
20 # the file COPYING, distributed as part of this software.
20 # the file COPYING, distributed as part of this software.
21 #-----------------------------------------------------------------------------
21 #-----------------------------------------------------------------------------
22
22
23 #-----------------------------------------------------------------------------
23 #-----------------------------------------------------------------------------
24 # Imports
24 # Imports
25 #-----------------------------------------------------------------------------
25 #-----------------------------------------------------------------------------
26
26
27 import objc
27 import objc
28 import uuid
28 import uuid
29
29
30 from Foundation import NSObject, NSMutableArray, NSMutableDictionary,\
30 from Foundation import NSObject, NSMutableArray, NSMutableDictionary,\
31 NSLog, NSNotificationCenter, NSMakeRange,\
31 NSLog, NSNotificationCenter, NSMakeRange,\
32 NSLocalizedString, NSIntersectionRange
32 NSLocalizedString, NSIntersectionRange
33
33
34 from AppKit import NSApplicationWillTerminateNotification, NSBeep,\
34 from AppKit import NSApplicationWillTerminateNotification, NSBeep,\
35 NSTextView, NSRulerView, NSVerticalRuler
35 NSTextView, NSRulerView, NSVerticalRuler
36
36
37 from pprint import saferepr
37 from pprint import saferepr
38
38
39 import IPython
39 import IPython
40 from IPython.kernel.engineservice import ThreadedEngineService
40 from IPython.kernel.engineservice import ThreadedEngineService
41 from IPython.frontend.frontendbase import FrontEndBase
41 from IPython.frontend.frontendbase import FrontEndBase
42
42
43 from twisted.internet.threads import blockingCallFromThread
43 from twisted.internet.threads import blockingCallFromThread
44 from twisted.python.failure import Failure
44 from twisted.python.failure import Failure
45
45
46 #------------------------------------------------------------------------------
46 #------------------------------------------------------------------------------
47 # Classes to implement the Cocoa frontend
47 # Classes to implement the Cocoa frontend
48 #------------------------------------------------------------------------------
48 #------------------------------------------------------------------------------
49
49
50 # TODO:
50 # TODO:
51 # 1. use MultiEngineClient and out-of-process engine rather than
51 # 1. use MultiEngineClient and out-of-process engine rather than
52 # ThreadedEngineService?
52 # ThreadedEngineService?
53 # 2. integrate Xgrid launching of engines
53 # 2. integrate Xgrid launching of engines
54
54
55
55
56
56
57
57
58 class IPythonCocoaController(NSObject, FrontEndBase):
58 class IPythonCocoaController(NSObject, FrontEndBase):
59 userNS = objc.ivar() #mirror of engine.user_ns (key=>str(value))
59 userNS = objc.ivar() #mirror of engine.user_ns (key=>str(value))
60 waitingForEngine = objc.ivar().bool()
60 waitingForEngine = objc.ivar().bool()
61 textView = objc.IBOutlet()
61 textView = objc.IBOutlet()
62
62
63 def init(self):
63 def init(self):
64 self = super(IPythonCocoaController, self).init()
64 self = super(IPythonCocoaController, self).init()
65 FrontEndBase.__init__(self, engine=ThreadedEngineService())
65 FrontEndBase.__init__(self, engine=ThreadedEngineService())
66 if(self != None):
66 if(self != None):
67 self._common_init()
67 self._common_init()
68
68
69 return self
69 return self
70
70
71 def _common_init(self):
71 def _common_init(self):
72 """_common_init"""
72 """_common_init"""
73
73
74 self.userNS = NSMutableDictionary.dictionary()
74 self.userNS = NSMutableDictionary.dictionary()
75 self.waitingForEngine = False
75 self.waitingForEngine = False
76
76
77 self.lines = {}
77 self.lines = {}
78 self.tabSpaces = 4
78 self.tabSpaces = 4
79 self.tabUsesSpaces = True
79 self.tabUsesSpaces = True
80 self.currentBlockID = self.next_block_ID()
80 self.currentBlockID = self.next_block_ID()
81 self.blockRanges = {} # blockID=>NSRange
81 self.blockRanges = {} # blockID=>NSRange
82
82
83
83
84 def awakeFromNib(self):
84 def awakeFromNib(self):
85 """awakeFromNib"""
85 """awakeFromNib"""
86
86
87 self._common_init()
87 self._common_init()
88
88
89 # Start the IPython engine
89 # Start the IPython engine
90 self.engine.startService()
90 self.engine.startService()
91 NSLog('IPython engine started')
91 NSLog('IPython engine started')
92
92
93 # Register for app termination
93 # Register for app termination
94 nc = NSNotificationCenter.defaultCenter()
94 nc = NSNotificationCenter.defaultCenter()
95 nc.addObserver_selector_name_object_(
95 nc.addObserver_selector_name_object_(
96 self,
96 self,
97 'appWillTerminate:',
97 'appWillTerminate:',
98 NSApplicationWillTerminateNotification,
98 NSApplicationWillTerminateNotification,
99 None)
99 None)
100
100
101 self.textView.setDelegate_(self)
101 self.textView.setDelegate_(self)
102 self.textView.enclosingScrollView().setHasVerticalRuler_(True)
102 self.textView.enclosingScrollView().setHasVerticalRuler_(True)
103 r = NSRulerView.alloc().initWithScrollView_orientation_(
103 r = NSRulerView.alloc().initWithScrollView_orientation_(
104 self.textView.enclosingScrollView(),
104 self.textView.enclosingScrollView(),
105 NSVerticalRuler)
105 NSVerticalRuler)
106 self.verticalRulerView = r
106 self.verticalRulerView = r
107 self.verticalRulerView.setClientView_(self.textView)
107 self.verticalRulerView.setClientView_(self.textView)
108 self._start_cli_banner()
108 self._start_cli_banner()
109
109
110
110
111 def appWillTerminate_(self, notification):
111 def appWillTerminate_(self, notification):
112 """appWillTerminate"""
112 """appWillTerminate"""
113
113
114 self.engine.stopService()
114 self.engine.stopService()
115
115
116
116
117 def complete(self, token):
117 def complete(self, token):
118 """Complete token in engine's user_ns
118 """Complete token in engine's user_ns
119
119
120 Parameters
120 Parameters
121 ----------
121 ----------
122 token : string
122 token : string
123
123
124 Result
124 Result
125 ------
125 ------
126 Deferred result of
126 Deferred result of
127 IPython.kernel.engineservice.IEngineBase.complete
127 IPython.kernel.engineservice.IEngineBase.complete
128 """
128 """
129
129
130 return self.engine.complete(token)
130 return self.engine.complete(token)
131
131
132
132
133 def execute(self, block, blockID=None):
133 def execute(self, block, blockID=None):
134 self.waitingForEngine = True
134 self.waitingForEngine = True
135 self.willChangeValueForKey_('commandHistory')
135 self.willChangeValueForKey_('commandHistory')
136 d = super(IPythonCocoaController, self).execute(block, blockID)
136 d = super(IPythonCocoaController, self).execute(block, blockID)
137 d.addBoth(self._engine_done)
137 d.addBoth(self._engine_done)
138 d.addCallback(self._update_user_ns)
138 d.addCallback(self._update_user_ns)
139
139
140 return d
140 return d
141
141
142
142
143 def _engine_done(self, x):
143 def _engine_done(self, x):
144 self.waitingForEngine = False
144 self.waitingForEngine = False
145 self.didChangeValueForKey_('commandHistory')
145 self.didChangeValueForKey_('commandHistory')
146 return x
146 return x
147
147
148 def _update_user_ns(self, result):
148 def _update_user_ns(self, result):
149 """Update self.userNS from self.engine's namespace"""
149 """Update self.userNS from self.engine's namespace"""
150 d = self.engine.keys()
150 d = self.engine.keys()
151 d.addCallback(self._get_engine_namespace_values_for_keys)
151 d.addCallback(self._get_engine_namespace_values_for_keys)
152
152
153 return result
153 return result
154
154
155
155
156 def _get_engine_namespace_values_for_keys(self, keys):
156 def _get_engine_namespace_values_for_keys(self, keys):
157 d = self.engine.pull(keys)
157 d = self.engine.pull(keys)
158 d.addCallback(self._store_engine_namespace_values, keys=keys)
158 d.addCallback(self._store_engine_namespace_values, keys=keys)
159
159
160
160
161 def _store_engine_namespace_values(self, values, keys=[]):
161 def _store_engine_namespace_values(self, values, keys=[]):
162 assert(len(values) == len(keys))
162 assert(len(values) == len(keys))
163 self.willChangeValueForKey_('userNS')
163 self.willChangeValueForKey_('userNS')
164 for (k,v) in zip(keys,values):
164 for (k,v) in zip(keys,values):
165 self.userNS[k] = saferepr(v)
165 self.userNS[k] = saferepr(v)
166 self.didChangeValueForKey_('userNS')
166 self.didChangeValueForKey_('userNS')
167
167
168
168
169 def update_cell_prompt(self, result):
169 def update_cell_prompt(self, result):
170 if(isinstance(result, Failure)):
170 if(isinstance(result, Failure)):
171 blockID = result.blockID
171 blockID = result.blockID
172 else:
172 else:
173 blockID = result['blockID']
173 blockID = result['blockID']
174
174
175
175
176 self.insert_text(self.input_prompt(result=result),
176 self.insert_text(self.input_prompt(result=result),
177 textRange=NSMakeRange(self.blockRanges[blockID].location,0),
177 textRange=NSMakeRange(self.blockRanges[blockID].location,0),
178 scrollToVisible=False
178 scrollToVisible=False
179 )
179 )
180
180
181 return result
181 return result
182
182
183
183
184 def render_result(self, result):
184 def render_result(self, result):
185 blockID = result['blockID']
185 blockID = result['blockID']
186 inputRange = self.blockRanges[blockID]
186 inputRange = self.blockRanges[blockID]
187 del self.blockRanges[blockID]
187 del self.blockRanges[blockID]
188
188
189 #print inputRange,self.current_block_range()
189 #print inputRange,self.current_block_range()
190 self.insert_text('\n' +
190 self.insert_text('\n' +
191 self.output_prompt(result) +
191 self.output_prompt(result) +
192 result.get('display',{}).get('pprint','') +
192 result.get('display',{}).get('pprint','') +
193 '\n\n',
193 '\n\n',
194 textRange=NSMakeRange(inputRange.location+inputRange.length,
194 textRange=NSMakeRange(inputRange.location+inputRange.length,
195 0))
195 0))
196 return result
196 return result
197
197
198
198
199 def render_error(self, failure):
199 def render_error(self, failure):
200 self.insert_text('\n\n'+str(failure)+'\n\n')
200 self.insert_text('\n\n'+str(failure)+'\n\n')
201 self.start_new_block()
201 self.start_new_block()
202 return failure
202 return failure
203
203
204
204
205 def _start_cli_banner(self):
205 def _start_cli_banner(self):
206 """Print banner"""
206 """Print banner"""
207
207
208 banner = """IPython1 %s -- An enhanced Interactive Python.""" % \
208 banner = """IPython1 %s -- An enhanced Interactive Python.""" % \
209 IPython.__version__
209 IPython.__version__
210
210
211 self.insert_text(banner + '\n\n')
211 self.insert_text(banner + '\n\n')
212
212
213
213
214 def start_new_block(self):
214 def start_new_block(self):
215 """"""
215 """"""
216
216
217 self.currentBlockID = self.next_block_ID()
217 self.currentBlockID = self.next_block_ID()
218
218
219
219
220
220
221 def next_block_ID(self):
221 def next_block_ID(self):
222
222
223 return uuid.uuid4()
223 return uuid.uuid4()
224
224
225 def current_block_range(self):
225 def current_block_range(self):
226 return self.blockRanges.get(self.currentBlockID,
226 return self.blockRanges.get(self.currentBlockID,
227 NSMakeRange(self.textView.textStorage().length(),
227 NSMakeRange(self.textView.textStorage().length(),
228 0))
228 0))
229
229
230 def current_block(self):
230 def current_block(self):
231 """The current block's text"""
231 """The current block's text"""
232
232
233 return self.text_for_range(self.current_block_range())
233 return self.text_for_range(self.current_block_range())
234
234
235 def text_for_range(self, textRange):
235 def text_for_range(self, textRange):
236 """text_for_range"""
236 """text_for_range"""
237
237
238 ts = self.textView.textStorage()
238 ts = self.textView.textStorage()
239 return ts.string().substringWithRange_(textRange)
239 return ts.string().substringWithRange_(textRange)
240
240
241 def current_line(self):
241 def current_line(self):
242 block = self.text_for_range(self.current_block_range())
242 block = self.text_for_range(self.current_block_range())
243 block = block.split('\n')
243 block = block.split('\n')
244 return block[-1]
244 return block[-1]
245
245
246
246
247 def insert_text(self, string=None, textRange=None, scrollToVisible=True):
247 def insert_text(self, string=None, textRange=None, scrollToVisible=True):
248 """Insert text into textView at textRange, updating blockRanges
248 """Insert text into textView at textRange, updating blockRanges
249 as necessary
249 as necessary
250 """
250 """
251
251
252 if(textRange == None):
252 if(textRange == None):
253 #range for end of text
253 #range for end of text
254 textRange = NSMakeRange(self.textView.textStorage().length(), 0)
254 textRange = NSMakeRange(self.textView.textStorage().length(), 0)
255
255
256 for r in self.blockRanges.itervalues():
256 for r in self.blockRanges.itervalues():
257 intersection = NSIntersectionRange(r,textRange)
257 intersection = NSIntersectionRange(r,textRange)
258 if(intersection.length == 0): #ranges don't intersect
258 if(intersection.length == 0): #ranges don't intersect
259 if r.location >= textRange.location:
259 if r.location >= textRange.location:
260 r.location += len(string)
260 r.location += len(string)
261 else: #ranges intersect
261 else: #ranges intersect
262 if(r.location <= textRange.location):
262 if(r.location <= textRange.location):
263 assert(intersection.length == textRange.length)
263 assert(intersection.length == textRange.length)
264 r.length += textRange.length
264 r.length += textRange.length
265 else:
265 else:
266 r.location += intersection.length
266 r.location += intersection.length
267
267
268 self.textView.replaceCharactersInRange_withString_(
268 self.textView.replaceCharactersInRange_withString_(
269 textRange, string)
269 textRange, string)
270 self.textView.setSelectedRange_(
270 self.textView.setSelectedRange_(
271 NSMakeRange(textRange.location+len(string), 0))
271 NSMakeRange(textRange.location+len(string), 0))
272 if(scrollToVisible):
272 if(scrollToVisible):
273 self.textView.scrollRangeToVisible_(textRange)
273 self.textView.scrollRangeToVisible_(textRange)
274
274
275
275
276
276
277
277
278 def replace_current_block_with_string(self, textView, string):
278 def replace_current_block_with_string(self, textView, string):
279 textView.replaceCharactersInRange_withString_(
279 textView.replaceCharactersInRange_withString_(
280 self.current_block_range(),
280 self.current_block_range(),
281 string)
281 string)
282 self.current_block_range().length = len(string)
282 self.current_block_range().length = len(string)
283 r = NSMakeRange(textView.textStorage().length(), 0)
283 r = NSMakeRange(textView.textStorage().length(), 0)
284 textView.scrollRangeToVisible_(r)
284 textView.scrollRangeToVisible_(r)
285 textView.setSelectedRange_(r)
285 textView.setSelectedRange_(r)
286
286
287
287
288 def current_indent_string(self):
288 def current_indent_string(self):
289 """returns string for indent or None if no indent"""
289 """returns string for indent or None if no indent"""
290
290
291 return self._indent_for_block(self.currentBlock())
291 return self._indent_for_block(self.current_block())
292
292
293
293
294 def _indent_for_block(self, block):
294 def _indent_for_block(self, block):
295 lines = block.split('\n')
295 lines = block.split('\n')
296 if(len(lines) > 1):
296 if(len(lines) > 1):
297 currentIndent = len(lines[-1]) - len(lines[-1].lstrip())
297 currentIndent = len(lines[-1]) - len(lines[-1].lstrip())
298 if(currentIndent == 0):
298 if(currentIndent == 0):
299 currentIndent = self.tabSpaces
299 currentIndent = self.tabSpaces
300
300
301 if(self.tabUsesSpaces):
301 if(self.tabUsesSpaces):
302 result = ' ' * currentIndent
302 result = ' ' * currentIndent
303 else:
303 else:
304 result = '\t' * (currentIndent/self.tabSpaces)
304 result = '\t' * (currentIndent/self.tabSpaces)
305 else:
305 else:
306 result = None
306 result = None
307
307
308 return result
308 return result
309
309
310
310
311 # NSTextView delegate methods...
311 # NSTextView delegate methods...
312 def textView_doCommandBySelector_(self, textView, selector):
312 def textView_doCommandBySelector_(self, textView, selector):
313 assert(textView == self.textView)
313 assert(textView == self.textView)
314 NSLog("textView_doCommandBySelector_: "+selector)
314 NSLog("textView_doCommandBySelector_: "+selector)
315
315
316
316
317 if(selector == 'insertNewline:'):
317 if(selector == 'insertNewline:'):
318 indent = self.current_indent_string()
318 indent = self.current_indent_string()
319 if(indent):
319 if(indent):
320 line = indent + self.current_line()
320 line = indent + self.current_line()
321 else:
321 else:
322 line = self.current_line()
322 line = self.current_line()
323
323
324 if(self.is_complete(self.current_block())):
324 if(self.is_complete(self.current_block())):
325 self.execute(self.current_block(),
325 self.execute(self.current_block(),
326 blockID=self.currentBlockID)
326 blockID=self.currentBlockID)
327 self.start_new_block()
327 self.start_new_block()
328
328
329 return True
329 return True
330
330
331 return False
331 return False
332
332
333 elif(selector == 'moveUp:'):
333 elif(selector == 'moveUp:'):
334 prevBlock = self.get_history_previous(self.current_block())
334 prevBlock = self.get_history_previous(self.current_block())
335 if(prevBlock != None):
335 if(prevBlock != None):
336 self.replace_current_block_with_string(textView, prevBlock)
336 self.replace_current_block_with_string(textView, prevBlock)
337 else:
337 else:
338 NSBeep()
338 NSBeep()
339 return True
339 return True
340
340
341 elif(selector == 'moveDown:'):
341 elif(selector == 'moveDown:'):
342 nextBlock = self.get_history_next()
342 nextBlock = self.get_history_next()
343 if(nextBlock != None):
343 if(nextBlock != None):
344 self.replace_current_block_with_string(textView, nextBlock)
344 self.replace_current_block_with_string(textView, nextBlock)
345 else:
345 else:
346 NSBeep()
346 NSBeep()
347 return True
347 return True
348
348
349 elif(selector == 'moveToBeginningOfParagraph:'):
349 elif(selector == 'moveToBeginningOfParagraph:'):
350 textView.setSelectedRange_(NSMakeRange(
350 textView.setSelectedRange_(NSMakeRange(
351 self.current_block_range().location,
351 self.current_block_range().location,
352 0))
352 0))
353 return True
353 return True
354 elif(selector == 'moveToEndOfParagraph:'):
354 elif(selector == 'moveToEndOfParagraph:'):
355 textView.setSelectedRange_(NSMakeRange(
355 textView.setSelectedRange_(NSMakeRange(
356 self.current_block_range().location + \
356 self.current_block_range().location + \
357 self.current_block_range().length, 0))
357 self.current_block_range().length, 0))
358 return True
358 return True
359 elif(selector == 'deleteToEndOfParagraph:'):
359 elif(selector == 'deleteToEndOfParagraph:'):
360 if(textView.selectedRange().location <= \
360 if(textView.selectedRange().location <= \
361 self.current_block_range().location):
361 self.current_block_range().location):
362 # Intersect the selected range with the current line range
362 # Intersect the selected range with the current line range
363 if(self.current_block_range().length < 0):
363 if(self.current_block_range().length < 0):
364 self.blockRanges[self.currentBlockID].length = 0
364 self.blockRanges[self.currentBlockID].length = 0
365
365
366 r = NSIntersectionRange(textView.rangesForUserTextChange()[0],
366 r = NSIntersectionRange(textView.rangesForUserTextChange()[0],
367 self.current_block_range())
367 self.current_block_range())
368
368
369 if(r.length > 0): #no intersection
369 if(r.length > 0): #no intersection
370 textView.setSelectedRange_(r)
370 textView.setSelectedRange_(r)
371
371
372 return False # don't actually handle the delete
372 return False # don't actually handle the delete
373
373
374 elif(selector == 'insertTab:'):
374 elif(selector == 'insertTab:'):
375 if(len(self.current_line().strip()) == 0): #only white space
375 if(len(self.current_line().strip()) == 0): #only white space
376 return False
376 return False
377 else:
377 else:
378 self.textView.complete_(self)
378 self.textView.complete_(self)
379 return True
379 return True
380
380
381 elif(selector == 'deleteBackward:'):
381 elif(selector == 'deleteBackward:'):
382 #if we're at the beginning of the current block, ignore
382 #if we're at the beginning of the current block, ignore
383 if(textView.selectedRange().location == \
383 if(textView.selectedRange().location == \
384 self.current_block_range().location):
384 self.current_block_range().location):
385 return True
385 return True
386 else:
386 else:
387 self.current_block_range().length-=1
387 self.current_block_range().length-=1
388 return False
388 return False
389 return False
389 return False
390
390
391
391
392 def textView_shouldChangeTextInRanges_replacementStrings_(self,
392 def textView_shouldChangeTextInRanges_replacementStrings_(self,
393 textView, ranges, replacementStrings):
393 textView, ranges, replacementStrings):
394 """
394 """
395 Delegate method for NSTextView.
395 Delegate method for NSTextView.
396
396
397 Refuse change text in ranges not at end, but make those changes at
397 Refuse change text in ranges not at end, but make those changes at
398 end.
398 end.
399 """
399 """
400
400
401 assert(len(ranges) == len(replacementStrings))
401 assert(len(ranges) == len(replacementStrings))
402 allow = True
402 allow = True
403 for r,s in zip(ranges, replacementStrings):
403 for r,s in zip(ranges, replacementStrings):
404 r = r.rangeValue()
404 r = r.rangeValue()
405 if(textView.textStorage().length() > 0 and
405 if(textView.textStorage().length() > 0 and
406 r.location < self.current_block_range().location):
406 r.location < self.current_block_range().location):
407 self.insert_text(s)
407 self.insert_text(s)
408 allow = False
408 allow = False
409
409
410
410
411 self.blockRanges.setdefault(self.currentBlockID,
411 self.blockRanges.setdefault(self.currentBlockID,
412 self.current_block_range()).length +=\
412 self.current_block_range()).length +=\
413 len(s)
413 len(s)
414
414
415 return allow
415 return allow
416
416
417 def textView_completions_forPartialWordRange_indexOfSelectedItem_(self,
417 def textView_completions_forPartialWordRange_indexOfSelectedItem_(self,
418 textView, words, charRange, index):
418 textView, words, charRange, index):
419 try:
419 try:
420 ts = textView.textStorage()
420 ts = textView.textStorage()
421 token = ts.string().substringWithRange_(charRange)
421 token = ts.string().substringWithRange_(charRange)
422 completions = blockingCallFromThread(self.complete, token)
422 completions = blockingCallFromThread(self.complete, token)
423 except:
423 except:
424 completions = objc.nil
424 completions = objc.nil
425 NSBeep()
425 NSBeep()
426
426
427 return (completions,0)
427 return (completions,0)
428
428
429
429
General Comments 0
You need to be logged in to leave comments. Login now