##// END OF EJS Templates
notebook: Print a warning (but do not abort) if no webbrowser can be found....
Bradley M. Froehle -
Show More
@@ -1,589 +1,590 b''
1 1 # coding: utf-8
2 2 """A tornado based IPython notebook server.
3 3
4 4 Authors:
5 5
6 6 * Brian Granger
7 7 """
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2008-2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19 # stdlib
20 20 import errno
21 21 import logging
22 22 import os
23 23 import random
24 24 import re
25 25 import select
26 26 import signal
27 27 import socket
28 28 import sys
29 29 import threading
30 30 import time
31 31 import webbrowser
32 32
33 33 # Third party
34 34 import zmq
35 35
36 36 # Install the pyzmq ioloop. This has to be done before anything else from
37 37 # tornado is imported.
38 38 from zmq.eventloop import ioloop
39 39 ioloop.install()
40 40
41 41 from tornado import httpserver
42 42 from tornado import web
43 43
44 44 # Our own libraries
45 45 from .kernelmanager import MappingKernelManager
46 46 from .handlers import (LoginHandler, LogoutHandler,
47 47 ProjectDashboardHandler, NewHandler, NamedNotebookHandler,
48 48 MainKernelHandler, KernelHandler, KernelActionHandler, IOPubHandler,
49 49 ShellHandler, NotebookRootHandler, NotebookHandler, NotebookCopyHandler,
50 50 RSTHandler, AuthenticatedFileHandler, PrintNotebookHandler,
51 51 MainClusterHandler, ClusterProfileHandler, ClusterActionHandler
52 52 )
53 53 from .notebookmanager import NotebookManager
54 54 from .clustermanager import ClusterManager
55 55
56 56 from IPython.config.application import catch_config_error, boolean_flag
57 57 from IPython.core.application import BaseIPythonApplication
58 58 from IPython.core.profiledir import ProfileDir
59 59 from IPython.frontend.consoleapp import IPythonConsoleApp
60 60 from IPython.lib.kernel import swallow_argv
61 61 from IPython.zmq.session import Session, default_secure
62 62 from IPython.zmq.zmqshell import ZMQInteractiveShell
63 63 from IPython.zmq.ipkernel import (
64 64 flags as ipkernel_flags,
65 65 aliases as ipkernel_aliases,
66 66 IPKernelApp
67 67 )
68 68 from IPython.utils.traitlets import Dict, Unicode, Integer, List, Enum, Bool
69 69 from IPython.utils import py3compat
70 70
71 71 #-----------------------------------------------------------------------------
72 72 # Module globals
73 73 #-----------------------------------------------------------------------------
74 74
75 75 _kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"
76 76 _kernel_action_regex = r"(?P<action>restart|interrupt)"
77 77 _notebook_id_regex = r"(?P<notebook_id>\w+-\w+-\w+-\w+-\w+)"
78 78 _profile_regex = r"(?P<profile>[a-zA-Z0-9]+)"
79 79 _cluster_action_regex = r"(?P<action>start|stop)"
80 80
81 81
82 82 LOCALHOST = '127.0.0.1'
83 83
84 84 _examples = """
85 85 ipython notebook # start the notebook
86 86 ipython notebook --profile=sympy # use the sympy profile
87 87 ipython notebook --pylab=inline # pylab in inline plotting mode
88 88 ipython notebook --certfile=mycert.pem # use SSL/TLS certificate
89 89 ipython notebook --port=5555 --ip=* # Listen on port 5555, all interfaces
90 90 """
91 91
92 92 #-----------------------------------------------------------------------------
93 93 # Helper functions
94 94 #-----------------------------------------------------------------------------
95 95
96 96 def url_path_join(a,b):
97 97 if a.endswith('/') and b.startswith('/'):
98 98 return a[:-1]+b
99 99 else:
100 100 return a+b
101 101
102 102 def random_ports(port, n):
103 103 """Generate a list of n random ports near the given port.
104 104
105 105 The first 5 ports will be sequential, and the remaining n-5 will be
106 106 randomly selected in the range [port-2*n, port+2*n].
107 107 """
108 108 for i in range(min(5, n)):
109 109 yield port + i
110 110 for i in range(n-5):
111 111 yield port + random.randint(-2*n, 2*n)
112 112
113 113 #-----------------------------------------------------------------------------
114 114 # The Tornado web application
115 115 #-----------------------------------------------------------------------------
116 116
117 117 class NotebookWebApplication(web.Application):
118 118
119 119 def __init__(self, ipython_app, kernel_manager, notebook_manager,
120 120 cluster_manager, log,
121 121 base_project_url, settings_overrides):
122 122 handlers = [
123 123 (r"/", ProjectDashboardHandler),
124 124 (r"/login", LoginHandler),
125 125 (r"/logout", LogoutHandler),
126 126 (r"/new", NewHandler),
127 127 (r"/%s" % _notebook_id_regex, NamedNotebookHandler),
128 128 (r"/%s/copy" % _notebook_id_regex, NotebookCopyHandler),
129 129 (r"/%s/print" % _notebook_id_regex, PrintNotebookHandler),
130 130 (r"/kernels", MainKernelHandler),
131 131 (r"/kernels/%s" % _kernel_id_regex, KernelHandler),
132 132 (r"/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler),
133 133 (r"/kernels/%s/iopub" % _kernel_id_regex, IOPubHandler),
134 134 (r"/kernels/%s/shell" % _kernel_id_regex, ShellHandler),
135 135 (r"/notebooks", NotebookRootHandler),
136 136 (r"/notebooks/%s" % _notebook_id_regex, NotebookHandler),
137 137 (r"/rstservice/render", RSTHandler),
138 138 (r"/files/(.*)", AuthenticatedFileHandler, {'path' : notebook_manager.notebook_dir}),
139 139 (r"/clusters", MainClusterHandler),
140 140 (r"/clusters/%s/%s" % (_profile_regex, _cluster_action_regex), ClusterActionHandler),
141 141 (r"/clusters/%s" % _profile_regex, ClusterProfileHandler),
142 142 ]
143 143 settings = dict(
144 144 template_path=os.path.join(os.path.dirname(__file__), "templates"),
145 145 static_path=os.path.join(os.path.dirname(__file__), "static"),
146 146 cookie_secret=os.urandom(1024),
147 147 login_url="/login",
148 148 )
149 149
150 150 # allow custom overrides for the tornado web app.
151 151 settings.update(settings_overrides)
152 152
153 153 # Python < 2.6.5 doesn't accept unicode keys in f(**kwargs), and
154 154 # base_project_url will always be unicode, which will in turn
155 155 # make the patterns unicode, and ultimately result in unicode
156 156 # keys in kwargs to handler._execute(**kwargs) in tornado.
157 157 # This enforces that base_project_url be ascii in that situation.
158 158 #
159 159 # Note that the URLs these patterns check against are escaped,
160 160 # and thus guaranteed to be ASCII: 'hΓ©llo' is really 'h%C3%A9llo'.
161 161 base_project_url = py3compat.unicode_to_str(base_project_url, 'ascii')
162 162
163 163 # prepend base_project_url onto the patterns that we match
164 164 new_handlers = []
165 165 for handler in handlers:
166 166 pattern = url_path_join(base_project_url, handler[0])
167 167 new_handler = tuple([pattern]+list(handler[1:]))
168 168 new_handlers.append( new_handler )
169 169
170 170 super(NotebookWebApplication, self).__init__(new_handlers, **settings)
171 171
172 172 self.kernel_manager = kernel_manager
173 173 self.notebook_manager = notebook_manager
174 174 self.cluster_manager = cluster_manager
175 175 self.ipython_app = ipython_app
176 176 self.read_only = self.ipython_app.read_only
177 177 self.log = log
178 178
179 179
180 180 #-----------------------------------------------------------------------------
181 181 # Aliases and Flags
182 182 #-----------------------------------------------------------------------------
183 183
184 184 flags = dict(ipkernel_flags)
185 185 flags['no-browser']=(
186 186 {'NotebookApp' : {'open_browser' : False}},
187 187 "Don't open the notebook in a browser after startup."
188 188 )
189 189 flags['no-mathjax']=(
190 190 {'NotebookApp' : {'enable_mathjax' : False}},
191 191 """Disable MathJax
192 192
193 193 MathJax is the javascript library IPython uses to render math/LaTeX. It is
194 194 very large, so you may want to disable it if you have a slow internet
195 195 connection, or for offline use of the notebook.
196 196
197 197 When disabled, equations etc. will appear as their untransformed TeX source.
198 198 """
199 199 )
200 200 flags['read-only'] = (
201 201 {'NotebookApp' : {'read_only' : True}},
202 202 """Allow read-only access to notebooks.
203 203
204 204 When using a password to protect the notebook server, this flag
205 205 allows unauthenticated clients to view the notebook list, and
206 206 individual notebooks, but not edit them, start kernels, or run
207 207 code.
208 208
209 209 If no password is set, the server will be entirely read-only.
210 210 """
211 211 )
212 212
213 213 # Add notebook manager flags
214 214 flags.update(boolean_flag('script', 'NotebookManager.save_script',
215 215 'Auto-save a .py script everytime the .ipynb notebook is saved',
216 216 'Do not auto-save .py scripts for every notebook'))
217 217
218 218 # the flags that are specific to the frontend
219 219 # these must be scrubbed before being passed to the kernel,
220 220 # or it will raise an error on unrecognized flags
221 221 notebook_flags = ['no-browser', 'no-mathjax', 'read-only', 'script', 'no-script']
222 222
223 223 aliases = dict(ipkernel_aliases)
224 224
225 225 aliases.update({
226 226 'ip': 'NotebookApp.ip',
227 227 'port': 'NotebookApp.port',
228 228 'port-retries': 'NotebookApp.port_retries',
229 229 'keyfile': 'NotebookApp.keyfile',
230 230 'certfile': 'NotebookApp.certfile',
231 231 'notebook-dir': 'NotebookManager.notebook_dir',
232 232 'browser': 'NotebookApp.browser',
233 233 })
234 234
235 235 # remove ipkernel flags that are singletons, and don't make sense in
236 236 # multi-kernel evironment:
237 237 aliases.pop('f', None)
238 238
239 239 notebook_aliases = [u'port', u'port-retries', u'ip', u'keyfile', u'certfile',
240 240 u'notebook-dir']
241 241
242 242 #-----------------------------------------------------------------------------
243 243 # NotebookApp
244 244 #-----------------------------------------------------------------------------
245 245
246 246 class NotebookApp(BaseIPythonApplication):
247 247
248 248 name = 'ipython-notebook'
249 249 default_config_file_name='ipython_notebook_config.py'
250 250
251 251 description = """
252 252 The IPython HTML Notebook.
253 253
254 254 This launches a Tornado based HTML Notebook Server that serves up an
255 255 HTML5/Javascript Notebook client.
256 256 """
257 257 examples = _examples
258 258
259 259 classes = IPythonConsoleApp.classes + [MappingKernelManager, NotebookManager]
260 260 flags = Dict(flags)
261 261 aliases = Dict(aliases)
262 262
263 263 kernel_argv = List(Unicode)
264 264
265 265 log_level = Enum((0,10,20,30,40,50,'DEBUG','INFO','WARN','ERROR','CRITICAL'),
266 266 default_value=logging.INFO,
267 267 config=True,
268 268 help="Set the log level by value or name.")
269 269
270 270 # create requested profiles by default, if they don't exist:
271 271 auto_create = Bool(True)
272 272
273 273 # file to be opened in the notebook server
274 274 file_to_run = Unicode('')
275 275
276 276 # Network related information.
277 277
278 278 ip = Unicode(LOCALHOST, config=True,
279 279 help="The IP address the notebook server will listen on."
280 280 )
281 281
282 282 def _ip_changed(self, name, old, new):
283 283 if new == u'*': self.ip = u''
284 284
285 285 port = Integer(8888, config=True,
286 286 help="The port the notebook server will listen on."
287 287 )
288 288 port_retries = Integer(50, config=True,
289 289 help="The number of additional ports to try if the specified port is not available."
290 290 )
291 291
292 292 certfile = Unicode(u'', config=True,
293 293 help="""The full path to an SSL/TLS certificate file."""
294 294 )
295 295
296 296 keyfile = Unicode(u'', config=True,
297 297 help="""The full path to a private key file for usage with SSL/TLS."""
298 298 )
299 299
300 300 password = Unicode(u'', config=True,
301 301 help="""Hashed password to use for web authentication.
302 302
303 303 To generate, type in a python/IPython shell:
304 304
305 305 from IPython.lib import passwd; passwd()
306 306
307 307 The string should be of the form type:salt:hashed-password.
308 308 """
309 309 )
310 310
311 311 open_browser = Bool(True, config=True,
312 312 help="""Whether to open in a browser after starting.
313 313 The specific browser used is platform dependent and
314 314 determined by the python standard library `webbrowser`
315 315 module, unless it is overridden using the --browser
316 316 (NotebookApp.browser) configuration option.
317 317 """)
318 318
319 319 browser = Unicode(u'', config=True,
320 320 help="""Specify what command to use to invoke a web
321 321 browser when opening the notebook. If not specified, the
322 322 default browser will be determined by the `webbrowser`
323 323 standard library module, which allows setting of the
324 324 BROWSER environment variable to override it.
325 325 """)
326 326
327 327 read_only = Bool(False, config=True,
328 328 help="Whether to prevent editing/execution of notebooks."
329 329 )
330 330
331 331 webapp_settings = Dict(config=True,
332 332 help="Supply overrides for the tornado.web.Application that the "
333 333 "IPython notebook uses.")
334 334
335 335 enable_mathjax = Bool(True, config=True,
336 336 help="""Whether to enable MathJax for typesetting math/TeX
337 337
338 338 MathJax is the javascript library IPython uses to render math/LaTeX. It is
339 339 very large, so you may want to disable it if you have a slow internet
340 340 connection, or for offline use of the notebook.
341 341
342 342 When disabled, equations etc. will appear as their untransformed TeX source.
343 343 """
344 344 )
345 345 def _enable_mathjax_changed(self, name, old, new):
346 346 """set mathjax url to empty if mathjax is disabled"""
347 347 if not new:
348 348 self.mathjax_url = u''
349 349
350 350 base_project_url = Unicode('/', config=True,
351 351 help='''The base URL for the notebook server''')
352 352 base_kernel_url = Unicode('/', config=True,
353 353 help='''The base URL for the kernel server''')
354 354 websocket_host = Unicode("", config=True,
355 355 help="""The hostname for the websocket server."""
356 356 )
357 357
358 358 mathjax_url = Unicode("", config=True,
359 359 help="""The url for MathJax.js."""
360 360 )
361 361 def _mathjax_url_default(self):
362 362 if not self.enable_mathjax:
363 363 return u''
364 364 static_path = self.webapp_settings.get("static_path", os.path.join(os.path.dirname(__file__), "static"))
365 365 static_url_prefix = self.webapp_settings.get("static_url_prefix",
366 366 "/static/")
367 367 if os.path.exists(os.path.join(static_path, 'mathjax', "MathJax.js")):
368 368 self.log.info("Using local MathJax")
369 369 return static_url_prefix+u"mathjax/MathJax.js"
370 370 else:
371 371 if self.certfile:
372 372 # HTTPS: load from Rackspace CDN, because SSL certificate requires it
373 373 base = u"https://c328740.ssl.cf1.rackcdn.com"
374 374 else:
375 375 base = u"http://cdn.mathjax.org"
376 376
377 377 url = base + u"/mathjax/latest/MathJax.js"
378 378 self.log.info("Using MathJax from CDN: %s", url)
379 379 return url
380 380
381 381 def _mathjax_url_changed(self, name, old, new):
382 382 if new and not self.enable_mathjax:
383 383 # enable_mathjax=False overrides mathjax_url
384 384 self.mathjax_url = u''
385 385 else:
386 386 self.log.info("Using MathJax: %s", new)
387 387
388 388 def parse_command_line(self, argv=None):
389 389 super(NotebookApp, self).parse_command_line(argv)
390 390 if argv is None:
391 391 argv = sys.argv[1:]
392 392
393 393 # Scrub frontend-specific flags
394 394 self.kernel_argv = swallow_argv(argv, notebook_aliases, notebook_flags)
395 395 # Kernel should inherit default config file from frontend
396 396 self.kernel_argv.append("--KernelApp.parent_appname='%s'"%self.name)
397 397
398 398 if self.extra_args:
399 399 f = os.path.abspath(self.extra_args[0])
400 400 if os.path.isdir(f):
401 401 nbdir = f
402 402 else:
403 403 self.file_to_run = f
404 404 nbdir = os.path.dirname(f)
405 405 self.config.NotebookManager.notebook_dir = nbdir
406 406
407 407 def init_configurables(self):
408 408 # force Session default to be secure
409 409 default_secure(self.config)
410 410 self.kernel_manager = MappingKernelManager(
411 411 config=self.config, log=self.log, kernel_argv=self.kernel_argv,
412 412 connection_dir = self.profile_dir.security_dir,
413 413 )
414 414 self.notebook_manager = NotebookManager(config=self.config, log=self.log)
415 415 self.log.info("Serving notebooks from %s", self.notebook_manager.notebook_dir)
416 416 self.notebook_manager.list_notebooks()
417 417 self.cluster_manager = ClusterManager(config=self.config, log=self.log)
418 418 self.cluster_manager.update_profiles()
419 419
420 420 def init_logging(self):
421 421 # This prevents double log messages because tornado use a root logger that
422 422 # self.log is a child of. The logging module dipatches log messages to a log
423 423 # and all of its ancenstors until propagate is set to False.
424 424 self.log.propagate = False
425 425
426 426 def init_webapp(self):
427 427 """initialize tornado webapp and httpserver"""
428 428 self.web_app = NotebookWebApplication(
429 429 self, self.kernel_manager, self.notebook_manager,
430 430 self.cluster_manager, self.log,
431 431 self.base_project_url, self.webapp_settings
432 432 )
433 433 if self.certfile:
434 434 ssl_options = dict(certfile=self.certfile)
435 435 if self.keyfile:
436 436 ssl_options['keyfile'] = self.keyfile
437 437 else:
438 438 ssl_options = None
439 439 self.web_app.password = self.password
440 440 self.http_server = httpserver.HTTPServer(self.web_app, ssl_options=ssl_options)
441 441 if ssl_options is None and not self.ip and not (self.read_only and not self.password):
442 442 self.log.critical('WARNING: the notebook server is listening on all IP addresses '
443 443 'but not using any encryption or authentication. This is highly '
444 444 'insecure and not recommended.')
445 445
446 446 success = None
447 447 for port in random_ports(self.port, self.port_retries+1):
448 448 try:
449 449 self.http_server.listen(port, self.ip)
450 450 except socket.error, e:
451 451 if e.errno != errno.EADDRINUSE:
452 452 raise
453 453 self.log.info('The port %i is already in use, trying another random port.' % port)
454 454 else:
455 455 self.port = port
456 456 success = True
457 457 break
458 458 if not success:
459 459 self.log.critical('ERROR: the notebook server could not be started because '
460 460 'no available port could be found.')
461 461 self.exit(1)
462 462
463 463 def init_signal(self):
464 464 # FIXME: remove this check when pyzmq dependency is >= 2.1.11
465 465 # safely extract zmq version info:
466 466 try:
467 467 zmq_v = zmq.pyzmq_version_info()
468 468 except AttributeError:
469 469 zmq_v = [ int(n) for n in re.findall(r'\d+', zmq.__version__) ]
470 470 if 'dev' in zmq.__version__:
471 471 zmq_v.append(999)
472 472 zmq_v = tuple(zmq_v)
473 473 if zmq_v >= (2,1,9):
474 474 # This won't work with 2.1.7 and
475 475 # 2.1.9-10 will log ugly 'Interrupted system call' messages,
476 476 # but it will work
477 477 signal.signal(signal.SIGINT, self._handle_sigint)
478 478 signal.signal(signal.SIGTERM, self._signal_stop)
479 479
480 480 def _handle_sigint(self, sig, frame):
481 481 """SIGINT handler spawns confirmation dialog"""
482 482 # register more forceful signal handler for ^C^C case
483 483 signal.signal(signal.SIGINT, self._signal_stop)
484 484 # request confirmation dialog in bg thread, to avoid
485 485 # blocking the App
486 486 thread = threading.Thread(target=self._confirm_exit)
487 487 thread.daemon = True
488 488 thread.start()
489 489
490 490 def _restore_sigint_handler(self):
491 491 """callback for restoring original SIGINT handler"""
492 492 signal.signal(signal.SIGINT, self._handle_sigint)
493 493
494 494 def _confirm_exit(self):
495 495 """confirm shutdown on ^C
496 496
497 497 A second ^C, or answering 'y' within 5s will cause shutdown,
498 498 otherwise original SIGINT handler will be restored.
499 499 """
500 500 # FIXME: remove this delay when pyzmq dependency is >= 2.1.11
501 501 time.sleep(0.1)
502 502 sys.stdout.write("Shutdown Notebook Server (y/[n])? ")
503 503 sys.stdout.flush()
504 504 r,w,x = select.select([sys.stdin], [], [], 5)
505 505 if r:
506 506 line = sys.stdin.readline()
507 507 if line.lower().startswith('y'):
508 508 self.log.critical("Shutdown confirmed")
509 509 ioloop.IOLoop.instance().stop()
510 510 return
511 511 else:
512 512 print "No answer for 5s:",
513 513 print "resuming operation..."
514 514 # no answer, or answer is no:
515 515 # set it back to original SIGINT handler
516 516 # use IOLoop.add_callback because signal.signal must be called
517 517 # from main thread
518 518 ioloop.IOLoop.instance().add_callback(self._restore_sigint_handler)
519 519
520 520 def _signal_stop(self, sig, frame):
521 521 self.log.critical("received signal %s, stopping", sig)
522 522 ioloop.IOLoop.instance().stop()
523 523
524 524 @catch_config_error
525 525 def initialize(self, argv=None):
526 526 self.init_logging()
527 527 super(NotebookApp, self).initialize(argv)
528 528 self.init_configurables()
529 529 self.init_webapp()
530 530 self.init_signal()
531 531
532 532 def cleanup_kernels(self):
533 533 """shutdown all kernels
534 534
535 535 The kernels will shutdown themselves when this process no longer exists,
536 536 but explicit shutdown allows the KernelManagers to cleanup the connection files.
537 537 """
538 538 self.log.info('Shutting down kernels')
539 539 km = self.kernel_manager
540 540 # copy list, since shutdown_kernel deletes keys
541 541 for kid in list(km.kernel_ids):
542 542 km.shutdown_kernel(kid)
543 543
544 544 def start(self):
545 545 ip = self.ip if self.ip else '[all ip addresses on your system]'
546 546 proto = 'https' if self.certfile else 'http'
547 547 info = self.log.info
548 548 info("The IPython Notebook is running at: %s://%s:%i%s" %
549 549 (proto, ip, self.port,self.base_project_url) )
550 550 info("Use Control-C to stop this server and shut down all kernels.")
551 551
552 552 if self.open_browser or self.file_to_run:
553 553 ip = self.ip or '127.0.0.1'
554 if self.browser:
555 browser = webbrowser.get(self.browser)
556 else:
557 browser = webbrowser.get()
554 try:
555 browser = webbrowser.get(self.browser or None)
556 except webbrowser.Error as e:
557 self.log.warn('No web browser found: %s.' % e)
558 browser = None
558 559
559 560 if self.file_to_run:
560 561 filename, _ = os.path.splitext(os.path.basename(self.file_to_run))
561 562 for nb in self.notebook_manager.list_notebooks():
562 563 if filename == nb['name']:
563 564 url = nb['notebook_id']
564 565 break
565 566 else:
566 567 url = ''
567 568 else:
568 569 url = ''
570 if browser:
569 571 b = lambda : browser.open("%s://%s:%i%s%s" % (proto, ip,
570 self.port, self.base_project_url, url),
571 new=2)
572 self.port, self.base_project_url, url), new=2)
572 573 threading.Thread(target=b).start()
573 574 try:
574 575 ioloop.IOLoop.instance().start()
575 576 except KeyboardInterrupt:
576 577 info("Interrupted...")
577 578 finally:
578 579 self.cleanup_kernels()
579 580
580 581
581 582 #-----------------------------------------------------------------------------
582 583 # Main entry point
583 584 #-----------------------------------------------------------------------------
584 585
585 586 def launch_new_instance():
586 587 app = NotebookApp.instance()
587 588 app.initialize()
588 589 app.start()
589 590
General Comments 0
You need to be logged in to leave comments. Login now