##// END OF EJS Templates
Added GTK support to ZeroMQ kernel....
Fernando Perez -
Show More
@@ -0,0 +1,15 b''
1 """GUI support for the IPython ZeroMQ kernel.
2
3 This package contains the various toolkit-dependent utilities we use to enable
4 coordination between the IPython kernel and the event loops of the various GUI
5 toolkits.
6 """
7
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2010 The IPython Development Team.
10 #
11 # Distributed under the terms of the BSD License.
12 #
13 # The full license is in the file COPYING.txt, distributed as part of this
14 # software.
15 #-----------------------------------------------------------------------------
@@ -0,0 +1,86 b''
1 """GUI support for the IPython ZeroMQ kernel - GTK toolkit support.
2 """
3 #-----------------------------------------------------------------------------
4 # Copyright (C) 2010 The IPython Development Team
5 #
6 # Distributed under the terms of the BSD License. The full license is in
7 # the file COPYING.txt, distributed as part of this software.
8 #-----------------------------------------------------------------------------
9
10 #-----------------------------------------------------------------------------
11 # Imports
12 #-----------------------------------------------------------------------------
13 # stdlib
14 import sys
15
16 # Third-party
17 import gobject
18 import gtk
19
20 #-----------------------------------------------------------------------------
21 # Classes and functions
22 #-----------------------------------------------------------------------------
23
24 class GTKEmbed(object):
25 """A class to embed a kernel into the GTK main event loop.
26 """
27 def __init__(self, kernel):
28 self.kernel = kernel
29 # These two will later store the real gtk functions when we hijack them
30 self.gtk_main = None
31 self.gtk_main_quit = None
32
33 def start(self):
34 """Starts the GTK main event loop and sets our kernel startup routine.
35 """
36 # Register our function to initiate the kernel and start gtk
37 gobject.idle_add(self._wire_kernel)
38 gtk.main()
39
40 def _wire_kernel(self):
41 """Initializes the kernel inside GTK.
42
43 This is meant to run only once at startup, so it does its job and
44 returns False to ensure it doesn't get run again by GTK.
45 """
46 self.gtk_main, self.gtk_main_quit = self._hijack_gtk()
47 gobject.timeout_add(int(1000*self.kernel._poll_interval),
48 self.iterate_kernel)
49 return False
50
51 def iterate_kernel(self):
52 """Run one iteration of the kernel and return True.
53
54 GTK timer functions must return True to be called again, so we make the
55 call to :meth:`do_one_iteration` and then return True for GTK.
56 """
57 self.kernel.do_one_iteration()
58 return True
59
60 def stop(self):
61 # FIXME: this one isn't getting called because we have no reliable
62 # kernel shutdown. We need to fix that: once the kernel has a
63 # shutdown mechanism, it can call this.
64 self.gtk_main_quit()
65 sys.exit()
66
67 def _hijack_gtk(self):
68 """Hijack a few key functions in GTK for IPython integration.
69
70 Modifies pyGTK's main and main_quit with a dummy so user code does not
71 block IPython. This allows us to use %run to run arbitrary pygtk
72 scripts from a long-lived IPython session, and when they attempt to
73 start or stop
74
75 Returns
76 -------
77 The original functions that have been hijacked:
78 - gtk.main
79 - gtk.main_quit
80 """
81 def dummy(*args, **kw):
82 pass
83 # save and trap main and main_quit from gtk
84 orig_main, gtk.main = gtk.main, dummy
85 orig_main_quit, gtk.main_quit = gtk.main_quit, dummy
86 return orig_main, orig_main_quit
@@ -1,519 +1,538 b''
1 1 #!/usr/bin/env python
2 2 """A simple interactive kernel that talks to a frontend over 0MQ.
3 3
4 4 Things to do:
5 5
6 6 * Implement `set_parent` logic. Right before doing exec, the Kernel should
7 7 call set_parent on all the PUB objects with the message about to be executed.
8 8 * Implement random port and security key logic.
9 9 * Implement control messages.
10 10 * Implement event loop and poll version.
11 11 """
12 12
13 13 #-----------------------------------------------------------------------------
14 14 # Imports
15 15 #-----------------------------------------------------------------------------
16 16 from __future__ import print_function
17 17
18 18 # Standard library imports.
19 19 import __builtin__
20 20 import sys
21 21 import time
22 22 import traceback
23 23
24 24 # System library imports.
25 25 import zmq
26 26
27 27 # Local imports.
28 28 from IPython.config.configurable import Configurable
29 29 from IPython.utils import io
30 30 from IPython.utils.jsonutil import json_clean
31 31 from IPython.lib import pylabtools
32 32 from IPython.utils.traitlets import Instance, Float
33 33 from entry_point import base_launch_kernel, make_argument_parser, make_kernel, \
34 34 start_kernel
35 35 from iostream import OutStream
36 36 from session import Session, Message
37 37 from zmqshell import ZMQInteractiveShell
38 38
39 39
40 40 #-----------------------------------------------------------------------------
41 41 # Main kernel class
42 42 #-----------------------------------------------------------------------------
43 43
44 44 class Kernel(Configurable):
45 45
46 46 #---------------------------------------------------------------------------
47 47 # Kernel interface
48 48 #---------------------------------------------------------------------------
49 49
50 50 shell = Instance('IPython.core.interactiveshell.InteractiveShellABC')
51 51 session = Instance(Session)
52 52 reply_socket = Instance('zmq.Socket')
53 53 pub_socket = Instance('zmq.Socket')
54 54 req_socket = Instance('zmq.Socket')
55 55
56 56 # Private interface
57 57
58 58 # Time to sleep after flushing the stdout/err buffers in each execute
59 59 # cycle. While this introduces a hard limit on the minimal latency of the
60 60 # execute cycle, it helps prevent output synchronization problems for
61 61 # clients.
62 62 # Units are in seconds. The minimum zmq latency on local host is probably
63 63 # ~150 microseconds, set this to 500us for now. We may need to increase it
64 64 # a little if it's not enough after more interactive testing.
65 65 _execute_sleep = Float(0.0005, config=True)
66 66
67 67 # Frequency of the kernel's event loop.
68 68 # Units are in seconds, kernel subclasses for GUI toolkits may need to
69 69 # adapt to milliseconds.
70 70 _poll_interval = Float(0.05, config=True)
71 71
72 72 def __init__(self, **kwargs):
73 73 super(Kernel, self).__init__(**kwargs)
74 74
75 75 # Initialize the InteractiveShell subclass
76 76 self.shell = ZMQInteractiveShell.instance()
77 77 self.shell.displayhook.session = self.session
78 78 self.shell.displayhook.pub_socket = self.pub_socket
79 79
80 80 # TMP - hack while developing
81 81 self.shell._reply_content = None
82 82
83 83 # Build dict of handlers for message types
84 84 msg_types = [ 'execute_request', 'complete_request',
85 85 'object_info_request', 'history_request' ]
86 86 self.handlers = {}
87 87 for msg_type in msg_types:
88 88 self.handlers[msg_type] = getattr(self, msg_type)
89 89
90 90 def do_one_iteration(self):
91 """Do one iteration of the kernel's evaluation loop.
92 """
91 93 try:
92 94 ident = self.reply_socket.recv(zmq.NOBLOCK)
93 95 except zmq.ZMQError, e:
94 96 if e.errno == zmq.EAGAIN:
95 97 return
96 98 else:
97 99 raise
98 100 # FIXME: Bug in pyzmq/zmq?
99 101 # assert self.reply_socket.rcvmore(), "Missing message part."
100 102 msg = self.reply_socket.recv_json()
101 103
102 104 # Print some info about this message and leave a '--->' marker, so it's
103 105 # easier to trace visually the message chain when debugging. Each
104 106 # handler prints its message at the end.
105 107 # Eventually we'll move these from stdout to a logger.
106 108 io.raw_print('\n*** MESSAGE TYPE:', msg['msg_type'], '***')
107 109 io.raw_print(' Content: ', msg['content'],
108 110 '\n --->\n ', sep='', end='')
109 111
110 112 # Find and call actual handler for message
111 113 handler = self.handlers.get(msg['msg_type'], None)
112 114 if handler is None:
113 115 io.raw_print_err("UNKNOWN MESSAGE TYPE:", msg)
114 116 else:
115 117 handler(ident, msg)
116 118
117 119 def start(self):
118 120 """ Start the kernel main loop.
119 121 """
120 122 while True:
121 123 time.sleep(self._poll_interval)
122 124 self.do_one_iteration()
123 125
124 126 #---------------------------------------------------------------------------
125 127 # Kernel request handlers
126 128 #---------------------------------------------------------------------------
127 129
128 130 def _publish_pyin(self, code, parent):
129 131 """Publish the code request on the pyin stream."""
130 132
131 133 pyin_msg = self.session.msg(u'pyin',{u'code':code}, parent=parent)
132 134 self.pub_socket.send_json(pyin_msg)
133 135
134 136 def execute_request(self, ident, parent):
135 137 try:
136 138 content = parent[u'content']
137 139 code = content[u'code']
138 140 silent = content[u'silent']
139 141 except:
140 142 io.raw_print_err("Got bad msg: ")
141 143 io.raw_print_err(Message(parent))
142 144 return
143 145
144 146 shell = self.shell # we'll need this a lot here
145 147
146 148 # Replace raw_input. Note that is not sufficient to replace
147 149 # raw_input in the user namespace.
148 150 raw_input = lambda prompt='': self._raw_input(prompt, ident, parent)
149 151 __builtin__.raw_input = raw_input
150 152
151 153 # Set the parent message of the display hook and out streams.
152 154 shell.displayhook.set_parent(parent)
153 155 sys.stdout.set_parent(parent)
154 156 sys.stderr.set_parent(parent)
155 157
156 158 # Re-broadcast our input for the benefit of listening clients, and
157 159 # start computing output
158 160 if not silent:
159 161 self._publish_pyin(code, parent)
160 162
161 163 reply_content = {}
162 164 try:
163 165 if silent:
164 166 # runcode uses 'exec' mode, so no displayhook will fire, and it
165 167 # doesn't call logging or history manipulations. Print
166 168 # statements in that code will obviously still execute.
167 169 shell.runcode(code)
168 170 else:
169 171 # FIXME: runlines calls the exception handler itself.
170 172 shell._reply_content = None
171 173 shell.runlines(code)
172 174 except:
173 175 status = u'error'
174 176 # FIXME: this code right now isn't being used yet by default,
175 177 # because the runlines() call above directly fires off exception
176 178 # reporting. This code, therefore, is only active in the scenario
177 179 # where runlines itself has an unhandled exception. We need to
178 180 # uniformize this, for all exception construction to come from a
179 181 # single location in the codbase.
180 182 etype, evalue, tb = sys.exc_info()
181 183 tb_list = traceback.format_exception(etype, evalue, tb)
182 184 reply_content.update(shell._showtraceback(etype, evalue, tb_list))
183 185 else:
184 186 status = u'ok'
185 187 reply_content[u'payload'] = shell.payload_manager.read_payload()
186 188 # Be agressive about clearing the payload because we don't want
187 189 # it to sit in memory until the next execute_request comes in.
188 190 shell.payload_manager.clear_payload()
189 191
190 192 reply_content[u'status'] = status
191 193 # Compute the execution counter so clients can display prompts
192 194 reply_content['execution_count'] = shell.displayhook.prompt_count
193 195
194 196 # FIXME - fish exception info out of shell, possibly left there by
195 197 # runlines. We'll need to clean up this logic later.
196 198 if shell._reply_content is not None:
197 199 reply_content.update(shell._reply_content)
198 200
199 201 # At this point, we can tell whether the main code execution succeeded
200 202 # or not. If it did, we proceed to evaluate user_variables/expressions
201 203 if reply_content['status'] == 'ok':
202 204 reply_content[u'user_variables'] = \
203 205 shell.get_user_variables(content[u'user_variables'])
204 206 reply_content[u'user_expressions'] = \
205 207 shell.eval_expressions(content[u'user_expressions'])
206 208 else:
207 209 # If there was an error, don't even try to compute variables or
208 210 # expressions
209 211 reply_content[u'user_variables'] = {}
210 212 reply_content[u'user_expressions'] = {}
211 213
212 214 # Send the reply.
213 215 reply_msg = self.session.msg(u'execute_reply', reply_content, parent)
214 216 io.raw_print(reply_msg)
215 217
216 218 # Flush output before sending the reply.
217 219 sys.stdout.flush()
218 220 sys.stderr.flush()
219 221 # FIXME: on rare occasions, the flush doesn't seem to make it to the
220 222 # clients... This seems to mitigate the problem, but we definitely need
221 223 # to better understand what's going on.
222 224 if self._execute_sleep:
223 225 time.sleep(self._execute_sleep)
224 226
225 227 self.reply_socket.send(ident, zmq.SNDMORE)
226 228 self.reply_socket.send_json(reply_msg)
227 229 if reply_msg['content']['status'] == u'error':
228 230 self._abort_queue()
229 231
230 232 def complete_request(self, ident, parent):
231 233 txt, matches = self._complete(parent)
232 234 matches = {'matches' : matches,
233 235 'matched_text' : txt,
234 236 'status' : 'ok'}
235 237 completion_msg = self.session.send(self.reply_socket, 'complete_reply',
236 238 matches, parent, ident)
237 239 io.raw_print(completion_msg)
238 240
239 241 def object_info_request(self, ident, parent):
240 242 object_info = self.shell.object_inspect(parent['content']['oname'])
241 243 # Before we send this object over, we turn it into a dict and we scrub
242 244 # it for JSON usage
243 245 oinfo = json_clean(object_info._asdict())
244 246 msg = self.session.send(self.reply_socket, 'object_info_reply',
245 247 oinfo, parent, ident)
246 248 io.raw_print(msg)
247 249
248 250 def history_request(self, ident, parent):
249 251 output = parent['content']['output']
250 252 index = parent['content']['index']
251 253 raw = parent['content']['raw']
252 254 hist = self.shell.get_history(index=index, raw=raw, output=output)
253 255 content = {'history' : hist}
254 256 msg = self.session.send(self.reply_socket, 'history_reply',
255 257 content, parent, ident)
256 258 io.raw_print(msg)
257 259
258 260 #---------------------------------------------------------------------------
259 261 # Protected interface
260 262 #---------------------------------------------------------------------------
261 263
262 264 def _abort_queue(self):
263 265 while True:
264 266 try:
265 267 ident = self.reply_socket.recv(zmq.NOBLOCK)
266 268 except zmq.ZMQError, e:
267 269 if e.errno == zmq.EAGAIN:
268 270 break
269 271 else:
270 272 assert self.reply_socket.rcvmore(), \
271 273 "Unexpected missing message part."
272 274 msg = self.reply_socket.recv_json()
273 275 io.raw_print("Aborting:\n", Message(msg))
274 276 msg_type = msg['msg_type']
275 277 reply_type = msg_type.split('_')[0] + '_reply'
276 278 reply_msg = self.session.msg(reply_type, {'status' : 'aborted'}, msg)
277 279 io.raw_print(reply_msg)
278 280 self.reply_socket.send(ident,zmq.SNDMORE)
279 281 self.reply_socket.send_json(reply_msg)
280 282 # We need to wait a bit for requests to come in. This can probably
281 283 # be set shorter for true asynchronous clients.
282 284 time.sleep(0.1)
283 285
284 286 def _raw_input(self, prompt, ident, parent):
285 287 # Flush output before making the request.
286 288 sys.stderr.flush()
287 289 sys.stdout.flush()
288 290
289 291 # Send the input request.
290 292 content = dict(prompt=prompt)
291 293 msg = self.session.msg(u'input_request', content, parent)
292 294 self.req_socket.send_json(msg)
293 295
294 296 # Await a response.
295 297 reply = self.req_socket.recv_json()
296 298 try:
297 299 value = reply['content']['value']
298 300 except:
299 301 io.raw_print_err("Got bad raw_input reply: ")
300 302 io.raw_print_err(Message(parent))
301 303 value = ''
302 304 return value
303 305
304 306 def _complete(self, msg):
305 307 c = msg['content']
306 308 try:
307 309 cpos = int(c['cursor_pos'])
308 310 except:
309 311 # If we don't get something that we can convert to an integer, at
310 312 # least attempt the completion guessing the cursor is at the end of
311 313 # the text, if there's any, and otherwise of the line
312 314 cpos = len(c['text'])
313 315 if cpos==0:
314 316 cpos = len(c['line'])
315 317 return self.shell.complete(c['text'], c['line'], cpos)
316 318
317 319 def _object_info(self, context):
318 320 symbol, leftover = self._symbol_from_context(context)
319 321 if symbol is not None and not leftover:
320 322 doc = getattr(symbol, '__doc__', '')
321 323 else:
322 324 doc = ''
323 325 object_info = dict(docstring = doc)
324 326 return object_info
325 327
326 328 def _symbol_from_context(self, context):
327 329 if not context:
328 330 return None, context
329 331
330 332 base_symbol_string = context[0]
331 333 symbol = self.shell.user_ns.get(base_symbol_string, None)
332 334 if symbol is None:
333 335 symbol = __builtin__.__dict__.get(base_symbol_string, None)
334 336 if symbol is None:
335 337 return None, context
336 338
337 339 context = context[1:]
338 340 for i, name in enumerate(context):
339 341 new_symbol = getattr(symbol, name, None)
340 342 if new_symbol is None:
341 343 return symbol, context[i:]
342 344 else:
343 345 symbol = new_symbol
344 346
345 347 return symbol, []
346 348
347 349
348 350 class QtKernel(Kernel):
349 351 """A Kernel subclass with Qt support."""
350 352
351 353 def start(self):
352 354 """Start a kernel with QtPy4 event loop integration."""
353 355
354 356 from PyQt4 import QtGui, QtCore
355 357 from IPython.lib.guisupport import (
356 358 get_app_qt4, start_event_loop_qt4
357 359 )
358 360 self.app = get_app_qt4([" "])
359 361 self.app.setQuitOnLastWindowClosed(False)
360 362 self.timer = QtCore.QTimer()
361 363 self.timer.timeout.connect(self.do_one_iteration)
362 364 # Units for the timer are in milliseconds
363 365 self.timer.start(1000*self._poll_interval)
364 366 start_event_loop_qt4(self.app)
365 367
366 368
367 369 class WxKernel(Kernel):
368 370 """A Kernel subclass with Wx support."""
369 371
370 372 def start(self):
371 373 """Start a kernel with wx event loop support."""
372 374
373 375 import wx
374 376 from IPython.lib.guisupport import start_event_loop_wx
375 377 doi = self.do_one_iteration
376 _poll_interval = self._poll_interval
378 # Wx uses milliseconds
379 poll_interval = int(1000*self._poll_interval)
377 380
378 381 # We have to put the wx.Timer in a wx.Frame for it to fire properly.
379 382 # We make the Frame hidden when we create it in the main app below.
380 383 class TimerFrame(wx.Frame):
381 384 def __init__(self, func):
382 385 wx.Frame.__init__(self, None, -1)
383 386 self.timer = wx.Timer(self)
384 387 # Units for the timer are in milliseconds
385 self.timer.Start(1000*_poll_interval)
388 self.timer.Start(poll_interval)
386 389 self.Bind(wx.EVT_TIMER, self.on_timer)
387 390 self.func = func
391
388 392 def on_timer(self, event):
389 393 self.func()
390 394
391 395 # We need a custom wx.App to create our Frame subclass that has the
392 396 # wx.Timer to drive the ZMQ event loop.
393 397 class IPWxApp(wx.App):
394 398 def OnInit(self):
395 399 self.frame = TimerFrame(doi)
396 400 self.frame.Show(False)
397 401 return True
398 402
399 403 # The redirect=False here makes sure that wx doesn't replace
400 404 # sys.stdout/stderr with its own classes.
401 405 self.app = IPWxApp(redirect=False)
402 406 start_event_loop_wx(self.app)
403 407
404 408
405 409 class TkKernel(Kernel):
406 410 """A Kernel subclass with Tk support."""
407 411
408 412 def start(self):
409 413 """Start a Tk enabled event loop."""
410 414
411 415 import Tkinter
412 416 doi = self.do_one_iteration
413
417 # Tk uses milliseconds
418 poll_interval = int(1000*self._poll_interval)
414 419 # For Tkinter, we create a Tk object and call its withdraw method.
415 420 class Timer(object):
416 421 def __init__(self, func):
417 422 self.app = Tkinter.Tk()
418 423 self.app.withdraw()
419 424 self.func = func
425
420 426 def on_timer(self):
421 427 self.func()
422 # Units for the timer are in milliseconds
423 self.app.after(1000*self._poll_interval, self.on_timer)
428 self.app.after(poll_interval, self.on_timer)
429
424 430 def start(self):
425 431 self.on_timer() # Call it once to get things going.
426 432 self.app.mainloop()
427 433
428 434 self.timer = Timer(doi)
429 435 self.timer.start()
430 436
437
438 class GTKKernel(Kernel):
439 """A Kernel subclass with GTK support."""
440
441 def start(self):
442 """Start the kernel, coordinating with the GTK event loop"""
443 from .gui.gtkembed import GTKEmbed
444
445 gtk_kernel = GTKEmbed(self)
446 gtk_kernel.start()
447
448
431 449 #-----------------------------------------------------------------------------
432 450 # Kernel main and launch functions
433 451 #-----------------------------------------------------------------------------
434 452
435 453 def launch_kernel(xrep_port=0, pub_port=0, req_port=0, hb_port=0,
436 454 independent=False, pylab=False):
437 """ Launches a localhost kernel, binding to the specified ports.
455 """Launches a localhost kernel, binding to the specified ports.
438 456
439 457 Parameters
440 458 ----------
441 459 xrep_port : int, optional
442 460 The port to use for XREP channel.
443 461
444 462 pub_port : int, optional
445 463 The port to use for the SUB channel.
446 464
447 465 req_port : int, optional
448 466 The port to use for the REQ (raw input) channel.
449 467
450 468 hb_port : int, optional
451 469 The port to use for the hearbeat REP channel.
452 470
453 471 independent : bool, optional (default False)
454 472 If set, the kernel process is guaranteed to survive if this process
455 473 dies. If not set, an effort is made to ensure that the kernel is killed
456 474 when this process dies. Note that in this case it is still good practice
457 475 to kill kernels manually before exiting.
458 476
459 477 pylab : bool or string, optional (default False)
460 478 If not False, the kernel will be launched with pylab enabled. If a
461 479 string is passed, matplotlib will use the specified backend. Otherwise,
462 480 matplotlib's default backend will be used.
463 481
464 482 Returns
465 483 -------
466 484 A tuple of form:
467 485 (kernel_process, xrep_port, pub_port, req_port)
468 486 where kernel_process is a Popen object and the ports are integers.
469 487 """
470 488 extra_arguments = []
471 489 if pylab:
472 490 extra_arguments.append('--pylab')
473 491 if isinstance(pylab, basestring):
474 492 extra_arguments.append(pylab)
475 493 return base_launch_kernel('from IPython.zmq.ipkernel import main; main()',
476 494 xrep_port, pub_port, req_port, hb_port,
477 495 independent, extra_arguments)
478 496
479 497
480 498 def main():
481 499 """ The IPython kernel main entry point.
482 500 """
483 501 parser = make_argument_parser()
484 502 parser.add_argument('--pylab', type=str, metavar='GUI', nargs='?',
485 503 const='auto', help = \
486 504 "Pre-load matplotlib and numpy for interactive use. If GUI is not \
487 505 given, the GUI backend is matplotlib's, otherwise use one of: \
488 506 ['tk', 'gtk', 'qt', 'wx', 'payload-svg'].")
489 507 namespace = parser.parse_args()
490 508
491 509 kernel_class = Kernel
492 510
493 _kernel_classes = {
511 kernel_classes = {
494 512 'qt' : QtKernel,
495 'qt4' : QtKernel,
513 'qt4': QtKernel,
496 514 'payload-svg': Kernel,
497 515 'wx' : WxKernel,
498 'tk' : TkKernel
516 'tk' : TkKernel,
517 'gtk': GTKKernel,
499 518 }
500 519 if namespace.pylab:
501 520 if namespace.pylab == 'auto':
502 521 gui, backend = pylabtools.find_gui_and_backend()
503 522 else:
504 523 gui, backend = pylabtools.find_gui_and_backend(namespace.pylab)
505 kernel_class = _kernel_classes.get(gui)
524 kernel_class = kernel_classes.get(gui)
506 525 if kernel_class is None:
507 526 raise ValueError('GUI is not supported: %r' % gui)
508 527 pylabtools.activate_matplotlib(backend)
509 528
510 529 kernel = make_kernel(namespace, kernel_class, OutStream)
511 530
512 531 if namespace.pylab:
513 532 pylabtools.import_pylab(kernel.shell.user_ns)
514 533
515 534 start_kernel(namespace, kernel)
516 535
517 536
518 537 if __name__ == '__main__':
519 538 main()
General Comments 0
You need to be logged in to leave comments. Login now