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