##// END OF EJS Templates
Splitting handlers into different files....
Brian E. Granger -
Show More
@@ -0,0 +1,196 b''
1 """Tornado handlers handling general files.
2
3 Authors:
4
5 * Brian Granger
6 """
7
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2011 The IPython Development Team
10 #
11 # Distributed under the terms of the BSD License. The full license is in
12 # the file COPYING, distributed as part of this software.
13 #-----------------------------------------------------------------------------
14
15 #-----------------------------------------------------------------------------
16 # Imports
17 #-----------------------------------------------------------------------------
18
19 import datetime
20 import email.utils
21 import hashlib
22 import logging
23 import mimetypes
24 import os
25 import stat
26 import threading
27
28 from tornado import web
29
30 try:
31 from tornado.log import app_log
32 except ImportError:
33 app_log = logging.getLogger()
34
35 from IPython.utils.path import filefind
36
37 #-----------------------------------------------------------------------------
38 # File handler
39 #-----------------------------------------------------------------------------
40
41 # to minimize subclass changes:
42 HTTPError = web.HTTPError
43
44 class FileFindHandler(web.StaticFileHandler):
45 """subclass of StaticFileHandler for serving files from a search path"""
46
47 _static_paths = {}
48 # _lock is needed for tornado < 2.2.0 compat
49 _lock = threading.Lock() # protects _static_hashes
50
51 def initialize(self, path, default_filename=None):
52 if isinstance(path, basestring):
53 path = [path]
54 self.roots = tuple(
55 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
56 )
57 self.default_filename = default_filename
58
59 @classmethod
60 def locate_file(cls, path, roots):
61 """locate a file to serve on our static file search path"""
62 with cls._lock:
63 if path in cls._static_paths:
64 return cls._static_paths[path]
65 try:
66 abspath = os.path.abspath(filefind(path, roots))
67 except IOError:
68 # empty string should always give exists=False
69 return ''
70
71 # os.path.abspath strips a trailing /
72 # it needs to be temporarily added back for requests to root/
73 if not (abspath + os.path.sep).startswith(roots):
74 raise HTTPError(403, "%s is not in root static directory", path)
75
76 cls._static_paths[path] = abspath
77 return abspath
78
79 def get(self, path, include_body=True):
80 path = self.parse_url_path(path)
81
82 # begin subclass override
83 abspath = self.locate_file(path, self.roots)
84 # end subclass override
85
86 if os.path.isdir(abspath) and self.default_filename is not None:
87 # need to look at the request.path here for when path is empty
88 # but there is some prefix to the path that was already
89 # trimmed by the routing
90 if not self.request.path.endswith("/"):
91 self.redirect(self.request.path + "/")
92 return
93 abspath = os.path.join(abspath, self.default_filename)
94 if not os.path.exists(abspath):
95 raise HTTPError(404)
96 if not os.path.isfile(abspath):
97 raise HTTPError(403, "%s is not a file", path)
98
99 stat_result = os.stat(abspath)
100 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
101
102 self.set_header("Last-Modified", modified)
103
104 mime_type, encoding = mimetypes.guess_type(abspath)
105 if mime_type:
106 self.set_header("Content-Type", mime_type)
107
108 cache_time = self.get_cache_time(path, modified, mime_type)
109
110 if cache_time > 0:
111 self.set_header("Expires", datetime.datetime.utcnow() + \
112 datetime.timedelta(seconds=cache_time))
113 self.set_header("Cache-Control", "max-age=" + str(cache_time))
114 else:
115 self.set_header("Cache-Control", "public")
116
117 self.set_extra_headers(path)
118
119 # Check the If-Modified-Since, and don't send the result if the
120 # content has not been modified
121 ims_value = self.request.headers.get("If-Modified-Since")
122 if ims_value is not None:
123 date_tuple = email.utils.parsedate(ims_value)
124 if_since = datetime.datetime(*date_tuple[:6])
125 if if_since >= modified:
126 self.set_status(304)
127 return
128
129 with open(abspath, "rb") as file:
130 data = file.read()
131 hasher = hashlib.sha1()
132 hasher.update(data)
133 self.set_header("Etag", '"%s"' % hasher.hexdigest())
134 if include_body:
135 self.write(data)
136 else:
137 assert self.request.method == "HEAD"
138 self.set_header("Content-Length", len(data))
139
140 @classmethod
141 def get_version(cls, settings, path):
142 """Generate the version string to be used in static URLs.
143
144 This method may be overridden in subclasses (but note that it
145 is a class method rather than a static method). The default
146 implementation uses a hash of the file's contents.
147
148 ``settings`` is the `Application.settings` dictionary and ``path``
149 is the relative location of the requested asset on the filesystem.
150 The returned value should be a string, or ``None`` if no version
151 could be determined.
152 """
153 # begin subclass override:
154 static_paths = settings['static_path']
155 if isinstance(static_paths, basestring):
156 static_paths = [static_paths]
157 roots = tuple(
158 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
159 )
160
161 try:
162 abs_path = filefind(path, roots)
163 except IOError:
164 app_log.error("Could not find static file %r", path)
165 return None
166
167 # end subclass override
168
169 with cls._lock:
170 hashes = cls._static_hashes
171 if abs_path not in hashes:
172 try:
173 f = open(abs_path, "rb")
174 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
175 f.close()
176 except Exception:
177 app_log.error("Could not open static file %r", path)
178 hashes[abs_path] = None
179 hsh = hashes.get(abs_path)
180 if hsh:
181 return hsh[:5]
182 return None
183
184
185 def parse_url_path(self, url_path):
186 """Converts a static URL path into a filesystem path.
187
188 ``url_path`` is the path component of the URL with
189 ``static_url_prefix`` removed. The return value should be
190 filesystem path relative to ``static_path``.
191 """
192 if os.path.sep != "/":
193 url_path = url_path.replace("/", os.path.sep)
194 return url_path
195
196
@@ -0,0 +1,31 b''
1 """Notebook related utilities
2
3 Authors:
4
5 * Brian Granger
6 """
7
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2011 The IPython Development Team
10 #
11 # Distributed under the terms of the BSD License. The full license is in
12 # the file COPYING, distributed as part of this software.
13 #-----------------------------------------------------------------------------
14
15 #-----------------------------------------------------------------------------
16 # Imports
17 #-----------------------------------------------------------------------------
18
19 def url_path_join(*pieces):
20 """Join components of url into a relative url
21
22 Use to prevent double slash when joining subpath. This will leave the
23 initial and final / in place
24 """
25 initial = pieces[0].startswith('/')
26 final = pieces[-1].endswith('/')
27 striped = [s.strip('/') for s in pieces]
28 result = '/'.join(s for s in striped if s)
29 if initial: result = '/' + result
30 if final: result = result + '/'
31 return result
This diff has been collapsed as it changes many lines, (661 lines changed) Show them Hide them
@@ -1,931 +1,276 b''
1 """Tornado handlers for the notebook.
1 """Base Tornado handlers for the notebook.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2008-2011 The IPython Development Team
9 # Copyright (C) 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 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
19 import logging
24 import mimetypes
20
25 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 from tornado import web
21 from tornado import web
33 from tornado import websocket
22 from tornado import websocket
34
23
35 try:
24 try:
36 from tornado.log import app_log
25 from tornado.log import app_log
37 except ImportError:
26 except ImportError:
38 app_log = logging.getLogger()
27 app_log = logging.getLogger()
39
28
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
42
43 from IPython.config import Application
29 from IPython.config import Application
44 from IPython.external.decorator import decorator
30 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
50
51 try:
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
31
56 #-----------------------------------------------------------------------------
32 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
33 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
34 #-----------------------------------------------------------------------------
59
35
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
36 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
37 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
38 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
39 # version as of Oct 30/2011) the version check fails, see the issue report:
64
40
65 # https://github.com/facebook/tornado/issues/385
41 # https://github.com/facebook/tornado/issues/385
66
42
67 # This issue has been fixed in Tornado post 2.1.1:
43 # This issue has been fixed in Tornado post 2.1.1:
68
44
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
45 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
46
71 # Here we manually apply the same patch as above so that users of IPython can
47 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
48 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
49 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
50 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
51
76 import tornado
52 import tornado
77
53
78 if tornado.version_info <= (2,1,1):
54 if tornado.version_info <= (2,1,1):
79
55
80 def _execute(self, transforms, *args, **kwargs):
56 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
57 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
58
83 self.open_args = args
59 self.open_args = args
84 self.open_kwargs = kwargs
60 self.open_kwargs = kwargs
85
61
86 # The difference between version 8 and 13 is that in 8 the
62 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
63 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
64 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
65 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
66 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
67 self.ws_connection.accept_connection()
92
68
93 elif self.request.headers.get("Sec-WebSocket-Version"):
69 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
70 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
71 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
72 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
73 self.stream.close()
98
74
99 else:
75 else:
100 self.ws_connection = WebSocketProtocol76(self)
76 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
77 self.ws_connection.accept_connection()
102
78
103 websocket.WebSocketHandler._execute = _execute
79 websocket.WebSocketHandler._execute = _execute
104 del _execute
80 del _execute
105
81
106 #-----------------------------------------------------------------------------
82 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
83 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
84 #-----------------------------------------------------------------------------
109
85
110 @decorator
86 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
87 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
88 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
89 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
90 else:
115 return f(self, *args, **kwargs)
91 return f(self, *args, **kwargs)
116
92
117 @decorator
93 @decorator
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
94 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
95 """authenticate this page *unless* readonly view is active.
120
96
121 In read-only mode, the notebook list and print view should
97 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
98 be accessible without authentication.
123 """
99 """
124
100
125 @web.authenticated
101 @web.authenticated
126 def auth_f(self, *args, **kwargs):
102 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
103 return f(self, *args, **kwargs)
128
104
129 if self.settings.get('read_only', False):
105 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
106 return f(self, *args, **kwargs)
131 else:
107 else:
132 return auth_f(self, *args, **kwargs)
108 return auth_f(self, *args, **kwargs)
133
109
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
137 Use to prevent double slash when joining subpath
138 """
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
142 #-----------------------------------------------------------------------------
110 #-----------------------------------------------------------------------------
143 # Top-level handlers
111 # Top-level handlers
144 #-----------------------------------------------------------------------------
112 #-----------------------------------------------------------------------------
145
113
146 class RequestHandler(web.RequestHandler):
114 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
115 """RequestHandler with default variable setting."""
148
116
149 def render(*args, **kwargs):
117 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
118 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
119 return web.RequestHandler.render(*args, **kwargs)
152
120
153 class AuthenticatedHandler(RequestHandler):
121 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
122 """A RequestHandler with an authenticated user."""
155
123
156 def clear_login_cookie(self):
124 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
125 self.clear_cookie(self.cookie_name)
158
126
159 def get_current_user(self):
127 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
128 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
129 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
130 if user_id == '':
163 user_id = 'anonymous'
131 user_id = 'anonymous'
164 if user_id is None:
132 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
133 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
134 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
135 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
136 user_id = 'anonymous'
169 return user_id
137 return user_id
170
138
171 @property
139 @property
172 def cookie_name(self):
140 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
141 return self.settings.get('cookie_name', '')
174
142
175 @property
143 @property
176 def password(self):
144 def password(self):
177 """our password"""
145 """our password"""
178 return self.settings.get('password', '')
146 return self.settings.get('password', '')
179
147
180 @property
148 @property
181 def logged_in(self):
149 def logged_in(self):
182 """Is a user currently logged in?
150 """Is a user currently logged in?
183
151
184 """
152 """
185 user = self.get_current_user()
153 user = self.get_current_user()
186 return (user and not user == 'anonymous')
154 return (user and not user == 'anonymous')
187
155
188 @property
156 @property
189 def login_available(self):
157 def login_available(self):
190 """May a user proceed to log in?
158 """May a user proceed to log in?
191
159
192 This returns True if login capability is available, irrespective of
160 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
161 whether the user is already logged in or not.
194
162
195 """
163 """
196 return bool(self.settings.get('password', ''))
164 return bool(self.settings.get('password', ''))
197
165
198 @property
166 @property
199 def read_only(self):
167 def read_only(self):
200 """Is the notebook read-only?
168 """Is the notebook read-only?
201
169
202 """
170 """
203 return self.settings.get('read_only', False)
171 return self.settings.get('read_only', False)
204
172
205
173
206 class IPythonHandler(AuthenticatedHandler):
174 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
175 """IPython-specific extensions to authenticated handling
208
176
209 Mostly property shortcuts to IPython-specific settings.
177 Mostly property shortcuts to IPython-specific settings.
210 """
178 """
211
179
212 @property
180 @property
213 def config(self):
181 def config(self):
214 return self.settings.get('config', None)
182 return self.settings.get('config', None)
215
183
216 @property
184 @property
217 def log(self):
185 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
186 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
187 if Application.initialized():
220 return Application.instance().log
188 return Application.instance().log
221 else:
189 else:
222 return app_log
190 return app_log
223
191
224 @property
192 @property
225 def use_less(self):
193 def use_less(self):
226 """Use less instead of css in templates"""
194 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
195 return self.settings.get('use_less', False)
228
196
229 #---------------------------------------------------------------
197 #---------------------------------------------------------------
230 # URLs
198 # URLs
231 #---------------------------------------------------------------
199 #---------------------------------------------------------------
232
200
233 @property
201 @property
234 def ws_url(self):
202 def ws_url(self):
235 """websocket url matching the current request
203 """websocket url matching the current request
236
204
237 turns http[s]://host[:port] into
205 turns http[s]://host[:port] into
238 ws[s]://host[:port]
206 ws[s]://host[:port]
239 """
207 """
240 proto = self.request.protocol.replace('http', 'ws')
208 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
209 host = self.settings.get('websocket_host', '')
242 # default to config value
210 # default to config value
243 if host == '':
211 if host == '':
244 host = self.request.host # get from request
212 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
213 return "%s://%s" % (proto, host)
246
214
247 @property
215 @property
248 def mathjax_url(self):
216 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
217 return self.settings.get('mathjax_url', '')
250
218
251 @property
219 @property
252 def base_project_url(self):
220 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
221 return self.settings.get('base_project_url', '/')
254
222
255 @property
223 @property
256 def base_kernel_url(self):
224 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
225 return self.settings.get('base_kernel_url', '/')
258
226
259 #---------------------------------------------------------------
227 #---------------------------------------------------------------
260 # Manager objects
228 # Manager objects
261 #---------------------------------------------------------------
229 #---------------------------------------------------------------
262
230
263 @property
231 @property
264 def kernel_manager(self):
232 def kernel_manager(self):
265 return self.settings['kernel_manager']
233 return self.settings['kernel_manager']
266
234
267 @property
235 @property
268 def notebook_manager(self):
236 def notebook_manager(self):
269 return self.settings['notebook_manager']
237 return self.settings['notebook_manager']
270
238
271 @property
239 @property
272 def cluster_manager(self):
240 def cluster_manager(self):
273 return self.settings['cluster_manager']
241 return self.settings['cluster_manager']
274
242
275 @property
243 @property
276 def project(self):
244 def project(self):
277 return self.notebook_manager.notebook_dir
245 return self.notebook_manager.notebook_dir
278
246
279 #---------------------------------------------------------------
247 #---------------------------------------------------------------
280 # template rendering
248 # template rendering
281 #---------------------------------------------------------------
249 #---------------------------------------------------------------
282
250
283 def get_template(self, name):
251 def get_template(self, name):
284 """Return the jinja template object for a given name"""
252 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
253 return self.settings['jinja2_env'].get_template(name)
286
254
287 def render_template(self, name, **ns):
255 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
256 ns.update(self.template_namespace)
289 template = self.get_template(name)
257 template = self.get_template(name)
290 return template.render(**ns)
258 return template.render(**ns)
291
259
292 @property
260 @property
293 def template_namespace(self):
261 def template_namespace(self):
294 return dict(
262 return dict(
295 base_project_url=self.base_project_url,
263 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
264 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
265 read_only=self.read_only,
298 logged_in=self.logged_in,
266 logged_in=self.logged_in,
299 login_available=self.login_available,
267 login_available=self.login_available,
300 use_less=self.use_less,
268 use_less=self.use_less,
301 )
269 )
302
270
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
271 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
272 """static files should only be accessible when logged in"""
305
273
306 @authenticate_unless_readonly
274 @authenticate_unless_readonly
307 def get(self, path):
275 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
276 return web.StaticFileHandler.get(self, path)
309
310
311 class ProjectDashboardHandler(IPythonHandler):
312
313 @authenticate_unless_readonly
314 def get(self):
315 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
317 project_component=self.project.split('/'),
318 ))
319
320
321 class LoginHandler(IPythonHandler):
322
323 def _render(self, message=None):
324 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
327 ))
328
329 def get(self):
330 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
333 self._render()
334
335 def post(self):
336 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
338 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
341 self._render(message={'error': 'Invalid password'})
342 return
343
344 self.redirect(self.get_argument('next', default=self.base_project_url))
345
346
347 class LogoutHandler(IPythonHandler):
348
349 def get(self):
350 self.clear_login_cookie()
351 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
353 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
356 self.write(self.render_template('logout.html',
357 message=message))
358
359
360 class NewHandler(IPythonHandler):
361
362 @web.authenticated
363 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366
367 class NamedNotebookHandler(IPythonHandler):
368
369 @authenticate_unless_readonly
370 def get(self, notebook_id):
371 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
375 project=self.project,
376 notebook_id=notebook_id,
377 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
379 )
380 )
381
382
383 #-----------------------------------------------------------------------------
384 # Kernel handlers
385 #-----------------------------------------------------------------------------
386
387
388 class MainKernelHandler(IPythonHandler):
389
390 @web.authenticated
391 def get(self):
392 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
395 @web.authenticated
396 def post(self):
397 km = self.kernel_manager
398 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
404
405
406 class KernelHandler(IPythonHandler):
407
408 SUPPORTED_METHODS = ('DELETE')
409
410 @web.authenticated
411 def delete(self, kernel_id):
412 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
415 self.finish()
416
417
418 class KernelActionHandler(IPythonHandler):
419
420 @web.authenticated
421 def post(self, kernel_id, action):
422 km = self.kernel_manager
423 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
426 if action == 'restart':
427 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
431 self.finish()
432
433
434 class ZMQStreamHandler(websocket.WebSocketHandler):
435
436 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
438 pass
439
440 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
442
443 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
447 """
448 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
450 try:
451 msg['header'].pop('date')
452 except KeyError:
453 pass
454 try:
455 msg['parent_header'].pop('date')
456 except KeyError:
457 pass
458 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
460
461 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
464 if self.stream.closed(): return
465 try:
466 msg = self._reserialize_reply(msg_list)
467 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
470 self.write_message(msg)
471
472 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
475 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
477 """
478 return True
479
480
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
483 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
488
489 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
491 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
496 try:
497 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
499 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
502 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
507 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
513
514
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
517 @property
518 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
520
521 def create_stream(self):
522 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
526 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
528
529 def on_first_message(self, msg):
530 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
533 self.close()
534 return
535 try:
536 self.create_stream()
537 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
539 # close the connection.
540 if not self.stream.closed():
541 self.stream.close()
542 self.close()
543 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
545
546 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
550
551 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
558
559
560 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
562
563 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
569 def on_close(self):
570 km = self.kernel_manager
571 if self.kernel_id in km:
572 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
574 )
575 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
577 )
578 super(IOPubHandler, self).on_close()
579
580 def _send_status_message(self, status):
581 msg = self.session.msg("status",
582 {'execution_state': status}
583 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
585
586 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
589
590 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
593
594 def on_message(self, msg):
595 """IOPub messages make no sense"""
596 pass
597
598 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
600
601 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
603
604
605 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
608
609 class NotebookRedirectHandler(IPythonHandler):
610
611 @authenticate_unless_readonly
612 def get(self, notebook_name):
613 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
619 else:
620 raise HTTPError(404)
621
622
623 class NotebookRootHandler(IPythonHandler):
624
625 @authenticate_unless_readonly
626 def get(self):
627 nbm = self.notebook_manager
628 km = self.kernel_manager
629 files = nbm.list_notebooks()
630 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
633
634 @web.authenticated
635 def post(self):
636 nbm = self.notebook_manager
637 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
640 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
646
647
648 class NotebookHandler(IPythonHandler):
649
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
652 @authenticate_unless_readonly
653 def get(self, notebook_id):
654 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
658 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
666
667 @web.authenticated
668 def put(self, notebook_id):
669 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
674 self.finish()
675
676 @web.authenticated
677 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
680 self.finish()
681
682
683 class NotebookCheckpointsHandler(IPythonHandler):
684
685 SUPPORTED_METHODS = ('GET', 'POST')
686
687 @web.authenticated
688 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
694
695 @web.authenticated
696 def post(self, notebook_id):
697 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
704
705 self.finish(data)
706
707
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
710 SUPPORTED_METHODS = ('POST', 'DELETE')
711
712 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
718 self.finish()
719
720 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
726 self.finish()
727
728
729 class NotebookCopyHandler(IPythonHandler):
730
731 @web.authenticated
732 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
735
736
737 #-----------------------------------------------------------------------------
738 # Cluster handlers
739 #-----------------------------------------------------------------------------
740
741
742 class MainClusterHandler(IPythonHandler):
743
744 @web.authenticated
745 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
748
749 class ClusterProfileHandler(IPythonHandler):
750
751 @web.authenticated
752 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
755
756 class ClusterActionHandler(IPythonHandler):
757
758 @web.authenticated
759 def post(self, profile, action):
760 cm = self.cluster_manager
761 if action == 'start':
762 n = self.get_argument('n',default=None)
763 if n is None:
764 data = cm.start_cluster(profile)
765 else:
766 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
768 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
931
This diff has been collapsed as it changes many lines, (880 lines changed) Show them Hide them
@@ -1,931 +1,57 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for cluster web service.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2008-2011 The IPython Development Team
9 # Copyright (C) 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 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
24 import mimetypes
25 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 from tornado import web
19 from tornado import web
33 from tornado import websocket
34
35 try:
36 from tornado.log import app_log
37 except ImportError:
38 app_log = logging.getLogger()
39
20
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
21 from zmq.utils import jsonapi
42
22
43 from IPython.config import Application
23 from .base import IPythonHandler
44 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
50
51 try:
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
56 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
59
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
64
65 # https://github.com/facebook/tornado/issues/385
66
67 # This issue has been fixed in Tornado post 2.1.1:
68
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
71 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
76 import tornado
77
78 if tornado.version_info <= (2,1,1):
79
80 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
83 self.open_args = args
84 self.open_kwargs = kwargs
85
86 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
92
93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
98
99 else:
100 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
102
103 websocket.WebSocketHandler._execute = _execute
104 del _execute
105
106 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
109
110 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
115 return f(self, *args, **kwargs)
116
117 @decorator
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
120
121 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
123 """
124
125 @web.authenticated
126 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
128
129 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
131 else:
132 return auth_f(self, *args, **kwargs)
133
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
137 Use to prevent double slash when joining subpath
138 """
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
142 #-----------------------------------------------------------------------------
143 # Top-level handlers
144 #-----------------------------------------------------------------------------
145
146 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
148
149 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
152
153 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
155
156 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
158
159 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
163 user_id = 'anonymous'
164 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
169 return user_id
170
171 @property
172 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
174
175 @property
176 def password(self):
177 """our password"""
178 return self.settings.get('password', '')
179
180 @property
181 def logged_in(self):
182 """Is a user currently logged in?
183
184 """
185 user = self.get_current_user()
186 return (user and not user == 'anonymous')
187
188 @property
189 def login_available(self):
190 """May a user proceed to log in?
191
192 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
194
195 """
196 return bool(self.settings.get('password', ''))
197
198 @property
199 def read_only(self):
200 """Is the notebook read-only?
201
202 """
203 return self.settings.get('read_only', False)
204
205
206 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
208
209 Mostly property shortcuts to IPython-specific settings.
210 """
211
212 @property
213 def config(self):
214 return self.settings.get('config', None)
215
216 @property
217 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
220 return Application.instance().log
221 else:
222 return app_log
223
224 @property
225 def use_less(self):
226 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
228
229 #---------------------------------------------------------------
230 # URLs
231 #---------------------------------------------------------------
232
233 @property
234 def ws_url(self):
235 """websocket url matching the current request
236
237 turns http[s]://host[:port] into
238 ws[s]://host[:port]
239 """
240 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
242 # default to config value
243 if host == '':
244 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
246
247 @property
248 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
250
251 @property
252 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
254
255 @property
256 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
258
259 #---------------------------------------------------------------
260 # Manager objects
261 #---------------------------------------------------------------
262
263 @property
264 def kernel_manager(self):
265 return self.settings['kernel_manager']
266
267 @property
268 def notebook_manager(self):
269 return self.settings['notebook_manager']
270
271 @property
272 def cluster_manager(self):
273 return self.settings['cluster_manager']
274
275 @property
276 def project(self):
277 return self.notebook_manager.notebook_dir
278
279 #---------------------------------------------------------------
280 # template rendering
281 #---------------------------------------------------------------
282
283 def get_template(self, name):
284 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
286
287 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
289 template = self.get_template(name)
290 return template.render(**ns)
291
292 @property
293 def template_namespace(self):
294 return dict(
295 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
298 logged_in=self.logged_in,
299 login_available=self.login_available,
300 use_less=self.use_less,
301 )
302
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
305
306 @authenticate_unless_readonly
307 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
309
310
311 class ProjectDashboardHandler(IPythonHandler):
312
313 @authenticate_unless_readonly
314 def get(self):
315 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
317 project_component=self.project.split('/'),
318 ))
319
320
321 class LoginHandler(IPythonHandler):
322
323 def _render(self, message=None):
324 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
327 ))
328
329 def get(self):
330 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
333 self._render()
334
335 def post(self):
336 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
338 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
341 self._render(message={'error': 'Invalid password'})
342 return
343
344 self.redirect(self.get_argument('next', default=self.base_project_url))
345
346
347 class LogoutHandler(IPythonHandler):
348
349 def get(self):
350 self.clear_login_cookie()
351 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
353 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
356 self.write(self.render_template('logout.html',
357 message=message))
358
359
360 class NewHandler(IPythonHandler):
361
362 @web.authenticated
363 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366
367 class NamedNotebookHandler(IPythonHandler):
368
369 @authenticate_unless_readonly
370 def get(self, notebook_id):
371 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
375 project=self.project,
376 notebook_id=notebook_id,
377 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
379 )
380 )
381
382
383 #-----------------------------------------------------------------------------
384 # Kernel handlers
385 #-----------------------------------------------------------------------------
386
387
388 class MainKernelHandler(IPythonHandler):
389
390 @web.authenticated
391 def get(self):
392 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
395 @web.authenticated
396 def post(self):
397 km = self.kernel_manager
398 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
404
405
406 class KernelHandler(IPythonHandler):
407
408 SUPPORTED_METHODS = ('DELETE')
409
410 @web.authenticated
411 def delete(self, kernel_id):
412 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
415 self.finish()
416
417
418 class KernelActionHandler(IPythonHandler):
419
420 @web.authenticated
421 def post(self, kernel_id, action):
422 km = self.kernel_manager
423 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
426 if action == 'restart':
427 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
431 self.finish()
432
433
434 class ZMQStreamHandler(websocket.WebSocketHandler):
435
436 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
438 pass
439
440 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
442
443 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
447 """
448 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
450 try:
451 msg['header'].pop('date')
452 except KeyError:
453 pass
454 try:
455 msg['parent_header'].pop('date')
456 except KeyError:
457 pass
458 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
460
461 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
464 if self.stream.closed(): return
465 try:
466 msg = self._reserialize_reply(msg_list)
467 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
470 self.write_message(msg)
471
472 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
475 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
477 """
478 return True
479
480
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
483 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
488
489 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
491 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
496 try:
497 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
499 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
502 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
507 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
513
514
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
517 @property
518 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
520
521 def create_stream(self):
522 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
526 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
528
529 def on_first_message(self, msg):
530 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
533 self.close()
534 return
535 try:
536 self.create_stream()
537 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
539 # close the connection.
540 if not self.stream.closed():
541 self.stream.close()
542 self.close()
543 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
545
546 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
550
551 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
558
559
560 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
562
563 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
569 def on_close(self):
570 km = self.kernel_manager
571 if self.kernel_id in km:
572 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
574 )
575 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
577 )
578 super(IOPubHandler, self).on_close()
579
580 def _send_status_message(self, status):
581 msg = self.session.msg("status",
582 {'execution_state': status}
583 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
585
586 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
589
590 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
593
594 def on_message(self, msg):
595 """IOPub messages make no sense"""
596 pass
597
598 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
600
601 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
603
604
605 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
608
609 class NotebookRedirectHandler(IPythonHandler):
610
611 @authenticate_unless_readonly
612 def get(self, notebook_name):
613 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
619 else:
620 raise HTTPError(404)
621
622
623 class NotebookRootHandler(IPythonHandler):
624
625 @authenticate_unless_readonly
626 def get(self):
627 nbm = self.notebook_manager
628 km = self.kernel_manager
629 files = nbm.list_notebooks()
630 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
633
634 @web.authenticated
635 def post(self):
636 nbm = self.notebook_manager
637 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
640 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
646
647
648 class NotebookHandler(IPythonHandler):
649
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
652 @authenticate_unless_readonly
653 def get(self, notebook_id):
654 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
658 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
666
667 @web.authenticated
668 def put(self, notebook_id):
669 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
674 self.finish()
675
676 @web.authenticated
677 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
680 self.finish()
681
682
683 class NotebookCheckpointsHandler(IPythonHandler):
684
685 SUPPORTED_METHODS = ('GET', 'POST')
686
687 @web.authenticated
688 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
694
695 @web.authenticated
696 def post(self, notebook_id):
697 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
704
705 self.finish(data)
706
707
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
710 SUPPORTED_METHODS = ('POST', 'DELETE')
711
712 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
718 self.finish()
719
720 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
726 self.finish()
727
728
729 class NotebookCopyHandler(IPythonHandler):
730
731 @web.authenticated
732 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
735
736
24
737 #-----------------------------------------------------------------------------
25 #-----------------------------------------------------------------------------
738 # Cluster handlers
26 # Cluster handlers
739 #-----------------------------------------------------------------------------
27 #-----------------------------------------------------------------------------
740
28
741
29
742 class MainClusterHandler(IPythonHandler):
30 class MainClusterHandler(IPythonHandler):
743
31
744 @web.authenticated
32 @web.authenticated
745 def get(self):
33 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
34 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
35
748
36
749 class ClusterProfileHandler(IPythonHandler):
37 class ClusterProfileHandler(IPythonHandler):
750
38
751 @web.authenticated
39 @web.authenticated
752 def get(self, profile):
40 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
41 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
42
755
43
756 class ClusterActionHandler(IPythonHandler):
44 class ClusterActionHandler(IPythonHandler):
757
45
758 @web.authenticated
46 @web.authenticated
759 def post(self, profile, action):
47 def post(self, profile, action):
760 cm = self.cluster_manager
48 cm = self.cluster_manager
761 if action == 'start':
49 if action == 'start':
762 n = self.get_argument('n',default=None)
50 n = self.get_argument('n',default=None)
763 if n is None:
51 if n is None:
764 data = cm.start_cluster(profile)
52 data = cm.start_cluster(profile)
765 else:
53 else:
766 data = cm.start_cluster(profile, int(n))
54 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
55 if action == 'stop':
768 data = cm.stop_cluster(profile)
56 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
57 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
931
This diff has been collapsed as it changes many lines, (682 lines changed) Show them Hide them
@@ -1,931 +1,251 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for the notebook.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
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 import Cookie
19 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
20 import logging
24 import mimetypes
25 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 from tornado import web
21 from tornado import web
33 from tornado import websocket
22 from tornado import websocket
34
23
35 try:
36 from tornado.log import app_log
37 except ImportError:
38 app_log = logging.getLogger()
39
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
24 from zmq.utils import jsonapi
42
25
43 from IPython.config import Application
44 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
26 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
27 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
28 from IPython.utils.py3compat import PY3
50
29
51 try:
30 from .base import IPythonHandler
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
56 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
59
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
64
65 # https://github.com/facebook/tornado/issues/385
66
67 # This issue has been fixed in Tornado post 2.1.1:
68
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
71 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
76 import tornado
77
78 if tornado.version_info <= (2,1,1):
79
80 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
83 self.open_args = args
84 self.open_kwargs = kwargs
85
86 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
92
93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
98
99 else:
100 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
102
103 websocket.WebSocketHandler._execute = _execute
104 del _execute
105
106 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
109
110 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
115 return f(self, *args, **kwargs)
116
117 @decorator
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
120
121 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
123 """
124
125 @web.authenticated
126 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
128
129 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
131 else:
132 return auth_f(self, *args, **kwargs)
133
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
137 Use to prevent double slash when joining subpath
138 """
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
142 #-----------------------------------------------------------------------------
143 # Top-level handlers
144 #-----------------------------------------------------------------------------
145
146 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
148
149 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
152
153 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
155
156 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
158
159 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
163 user_id = 'anonymous'
164 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
169 return user_id
170
171 @property
172 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
174
175 @property
176 def password(self):
177 """our password"""
178 return self.settings.get('password', '')
179
180 @property
181 def logged_in(self):
182 """Is a user currently logged in?
183
184 """
185 user = self.get_current_user()
186 return (user and not user == 'anonymous')
187
188 @property
189 def login_available(self):
190 """May a user proceed to log in?
191
192 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
194
195 """
196 return bool(self.settings.get('password', ''))
197
198 @property
199 def read_only(self):
200 """Is the notebook read-only?
201
202 """
203 return self.settings.get('read_only', False)
204
205
206 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
208
209 Mostly property shortcuts to IPython-specific settings.
210 """
211
212 @property
213 def config(self):
214 return self.settings.get('config', None)
215
216 @property
217 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
220 return Application.instance().log
221 else:
222 return app_log
223
224 @property
225 def use_less(self):
226 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
228
229 #---------------------------------------------------------------
230 # URLs
231 #---------------------------------------------------------------
232
233 @property
234 def ws_url(self):
235 """websocket url matching the current request
236
237 turns http[s]://host[:port] into
238 ws[s]://host[:port]
239 """
240 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
242 # default to config value
243 if host == '':
244 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
246
247 @property
248 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
250
251 @property
252 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
254
255 @property
256 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
258
259 #---------------------------------------------------------------
260 # Manager objects
261 #---------------------------------------------------------------
262
263 @property
264 def kernel_manager(self):
265 return self.settings['kernel_manager']
266
267 @property
268 def notebook_manager(self):
269 return self.settings['notebook_manager']
270
271 @property
272 def cluster_manager(self):
273 return self.settings['cluster_manager']
274
275 @property
276 def project(self):
277 return self.notebook_manager.notebook_dir
278
279 #---------------------------------------------------------------
280 # template rendering
281 #---------------------------------------------------------------
282
283 def get_template(self, name):
284 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
286
287 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
289 template = self.get_template(name)
290 return template.render(**ns)
291
292 @property
293 def template_namespace(self):
294 return dict(
295 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
298 logged_in=self.logged_in,
299 login_available=self.login_available,
300 use_less=self.use_less,
301 )
302
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
305
306 @authenticate_unless_readonly
307 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
309
310
311 class ProjectDashboardHandler(IPythonHandler):
312
313 @authenticate_unless_readonly
314 def get(self):
315 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
317 project_component=self.project.split('/'),
318 ))
319
320
321 class LoginHandler(IPythonHandler):
322
323 def _render(self, message=None):
324 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
327 ))
328
329 def get(self):
330 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
333 self._render()
334
335 def post(self):
336 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
338 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
341 self._render(message={'error': 'Invalid password'})
342 return
343
344 self.redirect(self.get_argument('next', default=self.base_project_url))
345
346
347 class LogoutHandler(IPythonHandler):
348
349 def get(self):
350 self.clear_login_cookie()
351 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
353 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
356 self.write(self.render_template('logout.html',
357 message=message))
358
359
360 class NewHandler(IPythonHandler):
361
362 @web.authenticated
363 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366
367 class NamedNotebookHandler(IPythonHandler):
368
369 @authenticate_unless_readonly
370 def get(self, notebook_id):
371 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
375 project=self.project,
376 notebook_id=notebook_id,
377 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
379 )
380 )
381
382
31
383 #-----------------------------------------------------------------------------
32 #-----------------------------------------------------------------------------
384 # Kernel handlers
33 # Kernel handlers
385 #-----------------------------------------------------------------------------
34 #-----------------------------------------------------------------------------
386
35
387
36
388 class MainKernelHandler(IPythonHandler):
37 class MainKernelHandler(IPythonHandler):
389
38
390 @web.authenticated
39 @web.authenticated
391 def get(self):
40 def get(self):
392 km = self.kernel_manager
41 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
42 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
43
395 @web.authenticated
44 @web.authenticated
396 def post(self):
45 def post(self):
397 km = self.kernel_manager
46 km = self.kernel_manager
398 nbm = self.notebook_manager
47 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
48 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
49 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
50 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
51 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
52 self.finish(jsonapi.dumps(data))
404
53
405
54
406 class KernelHandler(IPythonHandler):
55 class KernelHandler(IPythonHandler):
407
56
408 SUPPORTED_METHODS = ('DELETE')
57 SUPPORTED_METHODS = ('DELETE')
409
58
410 @web.authenticated
59 @web.authenticated
411 def delete(self, kernel_id):
60 def delete(self, kernel_id):
412 km = self.kernel_manager
61 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
62 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
63 self.set_status(204)
415 self.finish()
64 self.finish()
416
65
417
66
418 class KernelActionHandler(IPythonHandler):
67 class KernelActionHandler(IPythonHandler):
419
68
420 @web.authenticated
69 @web.authenticated
421 def post(self, kernel_id, action):
70 def post(self, kernel_id, action):
422 km = self.kernel_manager
71 km = self.kernel_manager
423 if action == 'interrupt':
72 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
73 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
74 self.set_status(204)
426 if action == 'restart':
75 if action == 'restart':
427 km.restart_kernel(kernel_id)
76 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
77 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
78 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
79 self.write(jsonapi.dumps(data))
431 self.finish()
80 self.finish()
432
81
433
82
434 class ZMQStreamHandler(websocket.WebSocketHandler):
83 class ZMQStreamHandler(websocket.WebSocketHandler):
435
84
436 def clear_cookie(self, *args, **kwargs):
85 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
86 """meaningless for websockets"""
438 pass
87 pass
439
88
440 def _reserialize_reply(self, msg_list):
89 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
90 """Reserialize a reply message using JSON.
442
91
443 This takes the msg list from the ZMQ socket, unserializes it using
92 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
93 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
94 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
95 be sent back to the browser.
447 """
96 """
448 idents, msg_list = self.session.feed_identities(msg_list)
97 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
98 msg = self.session.unserialize(msg_list)
450 try:
99 try:
451 msg['header'].pop('date')
100 msg['header'].pop('date')
452 except KeyError:
101 except KeyError:
453 pass
102 pass
454 try:
103 try:
455 msg['parent_header'].pop('date')
104 msg['parent_header'].pop('date')
456 except KeyError:
105 except KeyError:
457 pass
106 pass
458 msg.pop('buffers')
107 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
108 return jsonapi.dumps(msg, default=date_default)
460
109
461 def _on_zmq_reply(self, msg_list):
110 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
111 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
112 # eventloop but hasn't been called.
464 if self.stream.closed(): return
113 if self.stream.closed(): return
465 try:
114 try:
466 msg = self._reserialize_reply(msg_list)
115 msg = self._reserialize_reply(msg_list)
467 except Exception:
116 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
117 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
118 else:
470 self.write_message(msg)
119 self.write_message(msg)
471
120
472 def allow_draft76(self):
121 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
122 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
123
475 This has been disabled by default in tornado in release 2.2.0, and
124 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
125 support will be removed in later versions.
477 """
126 """
478 return True
127 return True
479
128
480
129
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
130 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
131
483 def open(self, kernel_id):
132 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
133 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
134 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
135 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
136 self.on_message = self.on_first_message
488
137
489 def _inject_cookie_message(self, msg):
138 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
139 """Inject the first message, which is the document cookie,
491 for authentication."""
140 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
141 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
142 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
143 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
144 msg = msg.encode('utf8', 'replace')
496 try:
145 try:
497 identity, msg = msg.split(':', 1)
146 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
147 self.session.session = identity.decode('ascii')
499 except Exception:
148 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
149 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
150
502 try:
151 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
152 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
153 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
154 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
155
507 def on_first_message(self, msg):
156 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
157 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
158 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
159 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
160 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
161 self.on_message = self.save_on_message
513
162
514
163
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
164 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
165
517 @property
166 @property
518 def max_msg_size(self):
167 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
168 return self.settings.get('max_msg_size', 65535)
520
169
521 def create_stream(self):
170 def create_stream(self):
522 km = self.kernel_manager
171 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
172 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
173 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
174
526 def initialize(self, *args, **kwargs):
175 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
176 self.zmq_stream = None
528
177
529 def on_first_message(self, msg):
178 def on_first_message(self, msg):
530 try:
179 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
180 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
181 except web.HTTPError:
533 self.close()
182 self.close()
534 return
183 return
535 try:
184 try:
536 self.create_stream()
185 self.create_stream()
537 except web.HTTPError:
186 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
187 # WebSockets don't response to traditional error codes so we
539 # close the connection.
188 # close the connection.
540 if not self.stream.closed():
189 if not self.stream.closed():
541 self.stream.close()
190 self.stream.close()
542 self.close()
191 self.close()
543 else:
192 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
193 self.zmq_stream.on_recv(self._on_zmq_reply)
545
194
546 def on_message(self, msg):
195 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
196 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
197 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
198 self.session.send(self.zmq_stream, msg)
550
199
551 def on_close(self):
200 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
201 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
202 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
203 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
204 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
205 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
206 self.zmq_stream.close()
558
207
559
208
560 class IOPubHandler(ZMQChannelHandler):
209 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
210 channel = 'iopub'
562
211
563 def create_stream(self):
212 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
213 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
214 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
215 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
216 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
217
569 def on_close(self):
218 def on_close(self):
570 km = self.kernel_manager
219 km = self.kernel_manager
571 if self.kernel_id in km:
220 if self.kernel_id in km:
572 km.remove_restart_callback(
221 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
222 self.kernel_id, self.on_kernel_restarted,
574 )
223 )
575 km.remove_restart_callback(
224 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
225 self.kernel_id, self.on_restart_failed, 'dead',
577 )
226 )
578 super(IOPubHandler, self).on_close()
227 super(IOPubHandler, self).on_close()
579
228
580 def _send_status_message(self, status):
229 def _send_status_message(self, status):
581 msg = self.session.msg("status",
230 msg = self.session.msg("status",
582 {'execution_state': status}
231 {'execution_state': status}
583 )
232 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
233 self.write_message(jsonapi.dumps(msg, default=date_default))
585
234
586 def on_kernel_restarted(self):
235 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
236 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
237 self._send_status_message('restarting')
589
238
590 def on_restart_failed(self):
239 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
240 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
241 self._send_status_message('dead')
593
242
594 def on_message(self, msg):
243 def on_message(self, msg):
595 """IOPub messages make no sense"""
244 """IOPub messages make no sense"""
596 pass
245 pass
597
246
598 class ShellHandler(ZMQChannelHandler):
247 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
248 channel = 'shell'
600
249
601 class StdinHandler(ZMQChannelHandler):
250 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
251 channel = 'stdin'
603
604
605 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
608
609 class NotebookRedirectHandler(IPythonHandler):
610
611 @authenticate_unless_readonly
612 def get(self, notebook_name):
613 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
619 else:
620 raise HTTPError(404)
621
622
623 class NotebookRootHandler(IPythonHandler):
624
625 @authenticate_unless_readonly
626 def get(self):
627 nbm = self.notebook_manager
628 km = self.kernel_manager
629 files = nbm.list_notebooks()
630 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
633
634 @web.authenticated
635 def post(self):
636 nbm = self.notebook_manager
637 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
640 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
646
647
648 class NotebookHandler(IPythonHandler):
649
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
652 @authenticate_unless_readonly
653 def get(self, notebook_id):
654 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
658 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
666
667 @web.authenticated
668 def put(self, notebook_id):
669 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
674 self.finish()
675
676 @web.authenticated
677 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
680 self.finish()
681
682
683 class NotebookCheckpointsHandler(IPythonHandler):
684
685 SUPPORTED_METHODS = ('GET', 'POST')
686
687 @web.authenticated
688 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
694
695 @web.authenticated
696 def post(self, notebook_id):
697 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
704
705 self.finish(data)
706
707
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
710 SUPPORTED_METHODS = ('POST', 'DELETE')
711
712 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
718 self.finish()
719
720 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
726 self.finish()
727
728
729 class NotebookCopyHandler(IPythonHandler):
730
731 @web.authenticated
732 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
735
736
737 #-----------------------------------------------------------------------------
738 # Cluster handlers
739 #-----------------------------------------------------------------------------
740
741
742 class MainClusterHandler(IPythonHandler):
743
744 @web.authenticated
745 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
748
749 class ClusterProfileHandler(IPythonHandler):
750
751 @web.authenticated
752 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
755
756 class ClusterActionHandler(IPythonHandler):
757
758 @web.authenticated
759 def post(self, profile, action):
760 cm = self.cluster_manager
761 if action == 'start':
762 n = self.get_argument('n',default=None)
763 if n is None:
764 data = cm.start_cluster(profile)
765 else:
766 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
768 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
931
This diff has been collapsed as it changes many lines, (882 lines changed) Show them Hide them
@@ -1,931 +1,57 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers logging into the notebook.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2008-2011 The IPython Development Team
9 # Copyright (C) 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 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
24 import mimetypes
25 import os
26 import stat
27 import threading
28 import time
29 import uuid
19 import uuid
30
20
31 from tornado.escape import url_escape
21 from tornado.escape import url_escape
32 from tornado import web
33 from tornado import websocket
34
22
35 try:
36 from tornado.log import app_log
37 except ImportError:
38 app_log = logging.getLogger()
39
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
42
43 from IPython.config import Application
44 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
23 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
50
51 try:
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
56 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
59
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
64
65 # https://github.com/facebook/tornado/issues/385
66
67 # This issue has been fixed in Tornado post 2.1.1:
68
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
71 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
76 import tornado
77
78 if tornado.version_info <= (2,1,1):
79
80 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
83 self.open_args = args
84 self.open_kwargs = kwargs
85
86 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
92
93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
98
99 else:
100 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
102
103 websocket.WebSocketHandler._execute = _execute
104 del _execute
105
106 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
109
110 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
115 return f(self, *args, **kwargs)
116
24
117 @decorator
25 from .base import IPythonHandler
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
120
121 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
123 """
124
125 @web.authenticated
126 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
128
129 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
131 else:
132 return auth_f(self, *args, **kwargs)
133
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
137 Use to prevent double slash when joining subpath
138 """
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
26
142 #-----------------------------------------------------------------------------
27 #-----------------------------------------------------------------------------
143 # Top-level handlers
28 # Handler
144 #-----------------------------------------------------------------------------
29 #-----------------------------------------------------------------------------
145
30
146 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
148
149 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
152
153 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
155
156 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
158
159 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
163 user_id = 'anonymous'
164 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
169 return user_id
170
171 @property
172 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
174
175 @property
176 def password(self):
177 """our password"""
178 return self.settings.get('password', '')
179
180 @property
181 def logged_in(self):
182 """Is a user currently logged in?
183
184 """
185 user = self.get_current_user()
186 return (user and not user == 'anonymous')
187
188 @property
189 def login_available(self):
190 """May a user proceed to log in?
191
192 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
194
195 """
196 return bool(self.settings.get('password', ''))
197
198 @property
199 def read_only(self):
200 """Is the notebook read-only?
201
202 """
203 return self.settings.get('read_only', False)
204
205
206 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
208
209 Mostly property shortcuts to IPython-specific settings.
210 """
211
212 @property
213 def config(self):
214 return self.settings.get('config', None)
215
216 @property
217 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
220 return Application.instance().log
221 else:
222 return app_log
223
224 @property
225 def use_less(self):
226 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
228
229 #---------------------------------------------------------------
230 # URLs
231 #---------------------------------------------------------------
232
233 @property
234 def ws_url(self):
235 """websocket url matching the current request
236
237 turns http[s]://host[:port] into
238 ws[s]://host[:port]
239 """
240 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
242 # default to config value
243 if host == '':
244 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
246
247 @property
248 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
250
251 @property
252 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
254
255 @property
256 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
258
259 #---------------------------------------------------------------
260 # Manager objects
261 #---------------------------------------------------------------
262
263 @property
264 def kernel_manager(self):
265 return self.settings['kernel_manager']
266
267 @property
268 def notebook_manager(self):
269 return self.settings['notebook_manager']
270
271 @property
272 def cluster_manager(self):
273 return self.settings['cluster_manager']
274
275 @property
276 def project(self):
277 return self.notebook_manager.notebook_dir
278
279 #---------------------------------------------------------------
280 # template rendering
281 #---------------------------------------------------------------
282
283 def get_template(self, name):
284 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
286
287 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
289 template = self.get_template(name)
290 return template.render(**ns)
291
292 @property
293 def template_namespace(self):
294 return dict(
295 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
298 logged_in=self.logged_in,
299 login_available=self.login_available,
300 use_less=self.use_less,
301 )
302
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
305
306 @authenticate_unless_readonly
307 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
309
310
311 class ProjectDashboardHandler(IPythonHandler):
312
313 @authenticate_unless_readonly
314 def get(self):
315 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
317 project_component=self.project.split('/'),
318 ))
319
320
31
321 class LoginHandler(IPythonHandler):
32 class LoginHandler(IPythonHandler):
322
33
323 def _render(self, message=None):
34 def _render(self, message=None):
324 self.write(self.render_template('login.html',
35 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
36 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
37 message=message,
327 ))
38 ))
328
39
329 def get(self):
40 def get(self):
330 if self.current_user:
41 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
42 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
43 else:
333 self._render()
44 self._render()
334
45
335 def post(self):
46 def post(self):
336 pwd = self.get_argument('password', default=u'')
47 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
48 if self.login_available:
338 if passwd_check(self.password, pwd):
49 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
50 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
51 else:
341 self._render(message={'error': 'Invalid password'})
52 self._render(message={'error': 'Invalid password'})
342 return
53 return
343
54
344 self.redirect(self.get_argument('next', default=self.base_project_url))
55 self.redirect(self.get_argument('next', default=self.base_project_url))
345
56
346
57
347 class LogoutHandler(IPythonHandler):
348
349 def get(self):
350 self.clear_login_cookie()
351 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
353 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
356 self.write(self.render_template('logout.html',
357 message=message))
358
359
360 class NewHandler(IPythonHandler):
361
362 @web.authenticated
363 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366
367 class NamedNotebookHandler(IPythonHandler):
368
369 @authenticate_unless_readonly
370 def get(self, notebook_id):
371 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
375 project=self.project,
376 notebook_id=notebook_id,
377 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
379 )
380 )
381
382
383 #-----------------------------------------------------------------------------
384 # Kernel handlers
385 #-----------------------------------------------------------------------------
386
387
388 class MainKernelHandler(IPythonHandler):
389
390 @web.authenticated
391 def get(self):
392 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
395 @web.authenticated
396 def post(self):
397 km = self.kernel_manager
398 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
404
405
406 class KernelHandler(IPythonHandler):
407
408 SUPPORTED_METHODS = ('DELETE')
409
410 @web.authenticated
411 def delete(self, kernel_id):
412 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
415 self.finish()
416
417
418 class KernelActionHandler(IPythonHandler):
419
420 @web.authenticated
421 def post(self, kernel_id, action):
422 km = self.kernel_manager
423 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
426 if action == 'restart':
427 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
431 self.finish()
432
433
434 class ZMQStreamHandler(websocket.WebSocketHandler):
435
436 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
438 pass
439
440 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
442
443 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
447 """
448 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
450 try:
451 msg['header'].pop('date')
452 except KeyError:
453 pass
454 try:
455 msg['parent_header'].pop('date')
456 except KeyError:
457 pass
458 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
460
461 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
464 if self.stream.closed(): return
465 try:
466 msg = self._reserialize_reply(msg_list)
467 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
470 self.write_message(msg)
471
472 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
475 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
477 """
478 return True
479
480
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
483 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
488
489 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
491 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
496 try:
497 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
499 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
502 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
507 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
513
514
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
517 @property
518 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
520
521 def create_stream(self):
522 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
526 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
528
529 def on_first_message(self, msg):
530 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
533 self.close()
534 return
535 try:
536 self.create_stream()
537 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
539 # close the connection.
540 if not self.stream.closed():
541 self.stream.close()
542 self.close()
543 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
545
546 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
550
551 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
558
559
560 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
562
563 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
569 def on_close(self):
570 km = self.kernel_manager
571 if self.kernel_id in km:
572 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
574 )
575 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
577 )
578 super(IOPubHandler, self).on_close()
579
580 def _send_status_message(self, status):
581 msg = self.session.msg("status",
582 {'execution_state': status}
583 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
585
586 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
589
590 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
593
594 def on_message(self, msg):
595 """IOPub messages make no sense"""
596 pass
597
598 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
600
601 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
603
604
605 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
608
609 class NotebookRedirectHandler(IPythonHandler):
610
611 @authenticate_unless_readonly
612 def get(self, notebook_name):
613 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
619 else:
620 raise HTTPError(404)
621
622
623 class NotebookRootHandler(IPythonHandler):
624
625 @authenticate_unless_readonly
626 def get(self):
627 nbm = self.notebook_manager
628 km = self.kernel_manager
629 files = nbm.list_notebooks()
630 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
633
634 @web.authenticated
635 def post(self):
636 nbm = self.notebook_manager
637 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
640 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
646
647
648 class NotebookHandler(IPythonHandler):
649
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
652 @authenticate_unless_readonly
653 def get(self, notebook_id):
654 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
658 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
666
667 @web.authenticated
668 def put(self, notebook_id):
669 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
674 self.finish()
675
676 @web.authenticated
677 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
680 self.finish()
681
682
683 class NotebookCheckpointsHandler(IPythonHandler):
684
685 SUPPORTED_METHODS = ('GET', 'POST')
686
687 @web.authenticated
688 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
694
695 @web.authenticated
696 def post(self, notebook_id):
697 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
704
705 self.finish(data)
706
707
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
710 SUPPORTED_METHODS = ('POST', 'DELETE')
711
712 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
718 self.finish()
719
720 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
726 self.finish()
727
728
729 class NotebookCopyHandler(IPythonHandler):
730
731 @web.authenticated
732 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
735
736
737 #-----------------------------------------------------------------------------
738 # Cluster handlers
739 #-----------------------------------------------------------------------------
740
741
742 class MainClusterHandler(IPythonHandler):
743
744 @web.authenticated
745 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
748
749 class ClusterProfileHandler(IPythonHandler):
750
751 @web.authenticated
752 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
755
756 class ClusterActionHandler(IPythonHandler):
757
758 @web.authenticated
759 def post(self, profile, action):
760 cm = self.cluster_manager
761 if action == 'start':
762 n = self.get_argument('n',default=None)
763 if n is None:
764 data = cm.start_cluster(profile)
765 else:
766 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
768 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
931
This diff has been collapsed as it changes many lines, (903 lines changed) Show them Hide them
@@ -1,931 +1,36 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for logging out of the notebook.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2008-2011 The IPython Development Team
9 # Copyright (C) 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 import Cookie
19 from .base import IPythonHandler
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
24 import mimetypes
25 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 from tornado import web
33 from tornado import websocket
34
35 try:
36 from tornado.log import app_log
37 except ImportError:
38 app_log = logging.getLogger()
39
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
42
43 from IPython.config import Application
44 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
50
51 try:
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
56 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
59
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
64
65 # https://github.com/facebook/tornado/issues/385
66
67 # This issue has been fixed in Tornado post 2.1.1:
68
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
71 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
76 import tornado
77
78 if tornado.version_info <= (2,1,1):
79
80 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
83 self.open_args = args
84 self.open_kwargs = kwargs
85
86 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
92
93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
98
99 else:
100 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
102
103 websocket.WebSocketHandler._execute = _execute
104 del _execute
105
106 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
109
110 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
115 return f(self, *args, **kwargs)
116
117 @decorator
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
120
121 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
123 """
124
125 @web.authenticated
126 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
128
129 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
131 else:
132 return auth_f(self, *args, **kwargs)
133
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
137 Use to prevent double slash when joining subpath
138 """
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
20
142 #-----------------------------------------------------------------------------
21 #-----------------------------------------------------------------------------
143 # Top-level handlers
22 # Handler
144 #-----------------------------------------------------------------------------
23 #-----------------------------------------------------------------------------
145
24
146 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
148
149 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
152
153 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
155
156 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
158
159 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
163 user_id = 'anonymous'
164 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
169 return user_id
170
171 @property
172 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
174
175 @property
176 def password(self):
177 """our password"""
178 return self.settings.get('password', '')
179
180 @property
181 def logged_in(self):
182 """Is a user currently logged in?
183
184 """
185 user = self.get_current_user()
186 return (user and not user == 'anonymous')
187
188 @property
189 def login_available(self):
190 """May a user proceed to log in?
191
192 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
194
195 """
196 return bool(self.settings.get('password', ''))
197
198 @property
199 def read_only(self):
200 """Is the notebook read-only?
201
202 """
203 return self.settings.get('read_only', False)
204
205
206 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
208
209 Mostly property shortcuts to IPython-specific settings.
210 """
211
212 @property
213 def config(self):
214 return self.settings.get('config', None)
215
216 @property
217 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
220 return Application.instance().log
221 else:
222 return app_log
223
224 @property
225 def use_less(self):
226 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
228
229 #---------------------------------------------------------------
230 # URLs
231 #---------------------------------------------------------------
232
233 @property
234 def ws_url(self):
235 """websocket url matching the current request
236
237 turns http[s]://host[:port] into
238 ws[s]://host[:port]
239 """
240 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
242 # default to config value
243 if host == '':
244 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
246
247 @property
248 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
250
251 @property
252 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
254
255 @property
256 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
258
259 #---------------------------------------------------------------
260 # Manager objects
261 #---------------------------------------------------------------
262
263 @property
264 def kernel_manager(self):
265 return self.settings['kernel_manager']
266
267 @property
268 def notebook_manager(self):
269 return self.settings['notebook_manager']
270
271 @property
272 def cluster_manager(self):
273 return self.settings['cluster_manager']
274
275 @property
276 def project(self):
277 return self.notebook_manager.notebook_dir
278
279 #---------------------------------------------------------------
280 # template rendering
281 #---------------------------------------------------------------
282
283 def get_template(self, name):
284 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
286
287 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
289 template = self.get_template(name)
290 return template.render(**ns)
291
292 @property
293 def template_namespace(self):
294 return dict(
295 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
298 logged_in=self.logged_in,
299 login_available=self.login_available,
300 use_less=self.use_less,
301 )
302
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
305
306 @authenticate_unless_readonly
307 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
309
310
311 class ProjectDashboardHandler(IPythonHandler):
312
313 @authenticate_unless_readonly
314 def get(self):
315 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
317 project_component=self.project.split('/'),
318 ))
319
320
321 class LoginHandler(IPythonHandler):
322
323 def _render(self, message=None):
324 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
327 ))
328
329 def get(self):
330 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
333 self._render()
334
335 def post(self):
336 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
338 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
341 self._render(message={'error': 'Invalid password'})
342 return
343
344 self.redirect(self.get_argument('next', default=self.base_project_url))
345
346
25
347 class LogoutHandler(IPythonHandler):
26 class LogoutHandler(IPythonHandler):
348
27
349 def get(self):
28 def get(self):
350 self.clear_login_cookie()
29 self.clear_login_cookie()
351 if self.login_available:
30 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
31 message = {'info': 'Successfully logged out.'}
353 else:
32 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
33 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
34 'is disabled.'}
356 self.write(self.render_template('logout.html',
35 self.write(self.render_template('logout.html',
357 message=message))
36 message=message))
358
359
360 class NewHandler(IPythonHandler):
361
362 @web.authenticated
363 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366
367 class NamedNotebookHandler(IPythonHandler):
368
369 @authenticate_unless_readonly
370 def get(self, notebook_id):
371 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
375 project=self.project,
376 notebook_id=notebook_id,
377 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
379 )
380 )
381
382
383 #-----------------------------------------------------------------------------
384 # Kernel handlers
385 #-----------------------------------------------------------------------------
386
387
388 class MainKernelHandler(IPythonHandler):
389
390 @web.authenticated
391 def get(self):
392 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
395 @web.authenticated
396 def post(self):
397 km = self.kernel_manager
398 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
404
405
406 class KernelHandler(IPythonHandler):
407
408 SUPPORTED_METHODS = ('DELETE')
409
410 @web.authenticated
411 def delete(self, kernel_id):
412 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
415 self.finish()
416
417
418 class KernelActionHandler(IPythonHandler):
419
420 @web.authenticated
421 def post(self, kernel_id, action):
422 km = self.kernel_manager
423 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
426 if action == 'restart':
427 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
431 self.finish()
432
433
434 class ZMQStreamHandler(websocket.WebSocketHandler):
435
436 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
438 pass
439
440 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
442
443 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
447 """
448 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
450 try:
451 msg['header'].pop('date')
452 except KeyError:
453 pass
454 try:
455 msg['parent_header'].pop('date')
456 except KeyError:
457 pass
458 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
460
461 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
464 if self.stream.closed(): return
465 try:
466 msg = self._reserialize_reply(msg_list)
467 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
470 self.write_message(msg)
471
472 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
475 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
477 """
478 return True
479
480
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
483 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
488
489 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
491 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
496 try:
497 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
499 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
502 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
507 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
513
514
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
517 @property
518 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
520
521 def create_stream(self):
522 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
526 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
528
529 def on_first_message(self, msg):
530 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
533 self.close()
534 return
535 try:
536 self.create_stream()
537 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
539 # close the connection.
540 if not self.stream.closed():
541 self.stream.close()
542 self.close()
543 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
545
546 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
550
551 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
558
559
560 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
562
563 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
569 def on_close(self):
570 km = self.kernel_manager
571 if self.kernel_id in km:
572 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
574 )
575 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
577 )
578 super(IOPubHandler, self).on_close()
579
580 def _send_status_message(self, status):
581 msg = self.session.msg("status",
582 {'execution_state': status}
583 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
585
586 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
589
590 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
593
594 def on_message(self, msg):
595 """IOPub messages make no sense"""
596 pass
597
598 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
600
601 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
603
604
605 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
608
609 class NotebookRedirectHandler(IPythonHandler):
610
611 @authenticate_unless_readonly
612 def get(self, notebook_name):
613 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
619 else:
620 raise HTTPError(404)
621
622
623 class NotebookRootHandler(IPythonHandler):
624
625 @authenticate_unless_readonly
626 def get(self):
627 nbm = self.notebook_manager
628 km = self.kernel_manager
629 files = nbm.list_notebooks()
630 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
633
634 @web.authenticated
635 def post(self):
636 nbm = self.notebook_manager
637 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
640 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
646
647
648 class NotebookHandler(IPythonHandler):
649
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
652 @authenticate_unless_readonly
653 def get(self, notebook_id):
654 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
658 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
666
667 @web.authenticated
668 def put(self, notebook_id):
669 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
674 self.finish()
675
676 @web.authenticated
677 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
680 self.finish()
681
682
683 class NotebookCheckpointsHandler(IPythonHandler):
684
685 SUPPORTED_METHODS = ('GET', 'POST')
686
687 @web.authenticated
688 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
694
695 @web.authenticated
696 def post(self, notebook_id):
697 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
704
705 self.finish(data)
706
707
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
710 SUPPORTED_METHODS = ('POST', 'DELETE')
711
712 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
718 self.finish()
719
720 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
726 self.finish()
727
728
729 class NotebookCopyHandler(IPythonHandler):
730
731 @web.authenticated
732 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
735
736
737 #-----------------------------------------------------------------------------
738 # Cluster handlers
739 #-----------------------------------------------------------------------------
740
741
742 class MainClusterHandler(IPythonHandler):
743
744 @web.authenticated
745 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
748
749 class ClusterProfileHandler(IPythonHandler):
750
751 @web.authenticated
752 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
755
756 class ClusterActionHandler(IPythonHandler):
757
758 @web.authenticated
759 def post(self, profile, action):
760 cm = self.cluster_manager
761 if action == 'start':
762 n = self.get_argument('n',default=None)
763 if n is None:
764 data = cm.start_cluster(profile)
765 else:
766 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
768 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
931
This diff has been collapsed as it changes many lines, (874 lines changed) Show them Hide them
@@ -1,931 +1,75 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for the live notebook view.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2008-2011 The IPython Development Team
9 # Copyright (C) 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 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
24 import mimetypes
25 import os
19 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 from tornado import web
20 from tornado import web
33 from tornado import websocket
21 HTTPError = web.HTTPError
34
35 try:
36 from tornado.log import app_log
37 except ImportError:
38 app_log = logging.getLogger()
39
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
42
43 from IPython.config import Application
44 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
50
51 try:
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
56 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
59
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
64
65 # https://github.com/facebook/tornado/issues/385
66
67 # This issue has been fixed in Tornado post 2.1.1:
68
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
71 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
76 import tornado
77
78 if tornado.version_info <= (2,1,1):
79
80 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
83 self.open_args = args
84 self.open_kwargs = kwargs
85
86 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
92
93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
98
99 else:
100 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
102
103 websocket.WebSocketHandler._execute = _execute
104 del _execute
105
106 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
109
110 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
115 return f(self, *args, **kwargs)
116
117 @decorator
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
120
121 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
123 """
124
125 @web.authenticated
126 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
128
129 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
131 else:
132 return auth_f(self, *args, **kwargs)
133
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
22
137 Use to prevent double slash when joining subpath
23 from .base import IPythonHandler, authenticate_unless_readonly
138 """
24 from ..utils import url_path_join
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
25
142 #-----------------------------------------------------------------------------
26 #-----------------------------------------------------------------------------
143 # Top-level handlers
27 # Handlers
144 #-----------------------------------------------------------------------------
28 #-----------------------------------------------------------------------------
145
29
146 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
148
149 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
152
153 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
155
156 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
158
159 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
163 user_id = 'anonymous'
164 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
169 return user_id
170
171 @property
172 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
174
175 @property
176 def password(self):
177 """our password"""
178 return self.settings.get('password', '')
179
180 @property
181 def logged_in(self):
182 """Is a user currently logged in?
183
184 """
185 user = self.get_current_user()
186 return (user and not user == 'anonymous')
187
188 @property
189 def login_available(self):
190 """May a user proceed to log in?
191
192 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
194
195 """
196 return bool(self.settings.get('password', ''))
197
198 @property
199 def read_only(self):
200 """Is the notebook read-only?
201
202 """
203 return self.settings.get('read_only', False)
204
205
206 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
208
209 Mostly property shortcuts to IPython-specific settings.
210 """
211
212 @property
213 def config(self):
214 return self.settings.get('config', None)
215
216 @property
217 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
220 return Application.instance().log
221 else:
222 return app_log
223
224 @property
225 def use_less(self):
226 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
228
229 #---------------------------------------------------------------
230 # URLs
231 #---------------------------------------------------------------
232
233 @property
234 def ws_url(self):
235 """websocket url matching the current request
236
237 turns http[s]://host[:port] into
238 ws[s]://host[:port]
239 """
240 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
242 # default to config value
243 if host == '':
244 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
246
247 @property
248 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
250
251 @property
252 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
254
255 @property
256 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
258
259 #---------------------------------------------------------------
260 # Manager objects
261 #---------------------------------------------------------------
262
263 @property
264 def kernel_manager(self):
265 return self.settings['kernel_manager']
266
267 @property
268 def notebook_manager(self):
269 return self.settings['notebook_manager']
270
271 @property
272 def cluster_manager(self):
273 return self.settings['cluster_manager']
274
275 @property
276 def project(self):
277 return self.notebook_manager.notebook_dir
278
279 #---------------------------------------------------------------
280 # template rendering
281 #---------------------------------------------------------------
282
283 def get_template(self, name):
284 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
286
287 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
289 template = self.get_template(name)
290 return template.render(**ns)
291
292 @property
293 def template_namespace(self):
294 return dict(
295 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
298 logged_in=self.logged_in,
299 login_available=self.login_available,
300 use_less=self.use_less,
301 )
302
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
305
306 @authenticate_unless_readonly
307 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
309
310
311 class ProjectDashboardHandler(IPythonHandler):
312
313 @authenticate_unless_readonly
314 def get(self):
315 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
317 project_component=self.project.split('/'),
318 ))
319
320
321 class LoginHandler(IPythonHandler):
322
323 def _render(self, message=None):
324 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
327 ))
328
329 def get(self):
330 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
333 self._render()
334
335 def post(self):
336 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
338 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
341 self._render(message={'error': 'Invalid password'})
342 return
343
344 self.redirect(self.get_argument('next', default=self.base_project_url))
345
346
347 class LogoutHandler(IPythonHandler):
348
349 def get(self):
350 self.clear_login_cookie()
351 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
353 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
356 self.write(self.render_template('logout.html',
357 message=message))
358
359
30
360 class NewHandler(IPythonHandler):
31 class NewHandler(IPythonHandler):
361
32
362 @web.authenticated
33 @web.authenticated
363 def get(self):
34 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
35 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
36 self.redirect('/' + url_path_join(self.base_project_url, notebook_id))
37
366
38
367 class NamedNotebookHandler(IPythonHandler):
39 class NamedNotebookHandler(IPythonHandler):
368
40
369 @authenticate_unless_readonly
41 @authenticate_unless_readonly
370 def get(self, notebook_id):
42 def get(self, notebook_id):
371 nbm = self.notebook_manager
43 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
44 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
45 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
46 self.write(self.render_template('notebook.html',
375 project=self.project,
47 project=self.project,
376 notebook_id=notebook_id,
48 notebook_id=notebook_id,
377 kill_kernel=False,
49 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
50 mathjax_url=self.mathjax_url,
379 )
51 )
380 )
52 )
381
53
382
54
383 #-----------------------------------------------------------------------------
384 # Kernel handlers
385 #-----------------------------------------------------------------------------
386
387
388 class MainKernelHandler(IPythonHandler):
389
390 @web.authenticated
391 def get(self):
392 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
395 @web.authenticated
396 def post(self):
397 km = self.kernel_manager
398 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
404
405
406 class KernelHandler(IPythonHandler):
407
408 SUPPORTED_METHODS = ('DELETE')
409
410 @web.authenticated
411 def delete(self, kernel_id):
412 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
415 self.finish()
416
417
418 class KernelActionHandler(IPythonHandler):
419
420 @web.authenticated
421 def post(self, kernel_id, action):
422 km = self.kernel_manager
423 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
426 if action == 'restart':
427 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
431 self.finish()
432
433
434 class ZMQStreamHandler(websocket.WebSocketHandler):
435
436 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
438 pass
439
440 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
442
443 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
447 """
448 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
450 try:
451 msg['header'].pop('date')
452 except KeyError:
453 pass
454 try:
455 msg['parent_header'].pop('date')
456 except KeyError:
457 pass
458 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
460
461 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
464 if self.stream.closed(): return
465 try:
466 msg = self._reserialize_reply(msg_list)
467 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
470 self.write_message(msg)
471
472 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
475 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
477 """
478 return True
479
480
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
483 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
488
489 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
491 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
496 try:
497 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
499 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
502 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
507 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
513
514
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
517 @property
518 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
520
521 def create_stream(self):
522 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
526 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
528
529 def on_first_message(self, msg):
530 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
533 self.close()
534 return
535 try:
536 self.create_stream()
537 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
539 # close the connection.
540 if not self.stream.closed():
541 self.stream.close()
542 self.close()
543 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
545
546 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
550
551 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
558
559
560 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
562
563 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
569 def on_close(self):
570 km = self.kernel_manager
571 if self.kernel_id in km:
572 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
574 )
575 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
577 )
578 super(IOPubHandler, self).on_close()
579
580 def _send_status_message(self, status):
581 msg = self.session.msg("status",
582 {'execution_state': status}
583 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
585
586 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
589
590 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
593
594 def on_message(self, msg):
595 """IOPub messages make no sense"""
596 pass
597
598 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
600
601 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
603
604
605 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
608
609 class NotebookRedirectHandler(IPythonHandler):
55 class NotebookRedirectHandler(IPythonHandler):
610
56
611 @authenticate_unless_readonly
57 @authenticate_unless_readonly
612 def get(self, notebook_name):
58 def get(self, notebook_name):
613 # strip trailing .ipynb:
59 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
60 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
61 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
62 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
63 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
64 return self.redirect(url)
619 else:
65 else:
620 raise HTTPError(404)
66 raise HTTPError(404)
621
67
622
68
623 class NotebookRootHandler(IPythonHandler):
624
625 @authenticate_unless_readonly
626 def get(self):
627 nbm = self.notebook_manager
628 km = self.kernel_manager
629 files = nbm.list_notebooks()
630 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
633
634 @web.authenticated
635 def post(self):
636 nbm = self.notebook_manager
637 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
640 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
646
647
648 class NotebookHandler(IPythonHandler):
649
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
652 @authenticate_unless_readonly
653 def get(self, notebook_id):
654 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
658 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
666
667 @web.authenticated
668 def put(self, notebook_id):
669 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
674 self.finish()
675
676 @web.authenticated
677 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
680 self.finish()
681
682
683 class NotebookCheckpointsHandler(IPythonHandler):
684
685 SUPPORTED_METHODS = ('GET', 'POST')
686
687 @web.authenticated
688 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
694
695 @web.authenticated
696 def post(self, notebook_id):
697 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
704
705 self.finish(data)
706
707
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
710 SUPPORTED_METHODS = ('POST', 'DELETE')
711
712 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
718 self.finish()
719
720 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
726 self.finish()
727
728
729 class NotebookCopyHandler(IPythonHandler):
69 class NotebookCopyHandler(IPythonHandler):
730
70
731 @web.authenticated
71 @web.authenticated
732 def get(self, notebook_id):
72 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
73 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
74 self.redirect('/'+url_path_join(self.base_project_url, notebook_id))
735
736
737 #-----------------------------------------------------------------------------
738 # Cluster handlers
739 #-----------------------------------------------------------------------------
740
741
742 class MainClusterHandler(IPythonHandler):
743
744 @web.authenticated
745 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
748
749 class ClusterProfileHandler(IPythonHandler):
750
751 @web.authenticated
752 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
755
756 class ClusterActionHandler(IPythonHandler):
757
758 @web.authenticated
759 def post(self, profile, action):
760 cm = self.cluster_manager
761 if action == 'start':
762 n = self.get_argument('n',default=None)
763 if n is None:
764 data = cm.start_cluster(profile)
765 else:
766 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
768 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
931
75
This diff has been collapsed as it changes many lines, (797 lines changed) Show them Hide them
@@ -1,931 +1,138 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for the notebooks web service.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
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 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
24 import mimetypes
25 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 from tornado import web
19 from tornado import web
33 from tornado import websocket
34
35 try:
36 from tornado.log import app_log
37 except ImportError:
38 app_log = logging.getLogger()
39
20
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
21 from zmq.utils import jsonapi
42
22
43 from IPython.config import Application
44 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
23 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
50
51 try:
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
56 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
59
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
64
65 # https://github.com/facebook/tornado/issues/385
66
67 # This issue has been fixed in Tornado post 2.1.1:
68
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
71 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
76 import tornado
77
78 if tornado.version_info <= (2,1,1):
79
80 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
83 self.open_args = args
84 self.open_kwargs = kwargs
85
86 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
92
93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
98
99 else:
100 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
102
103 websocket.WebSocketHandler._execute = _execute
104 del _execute
105
106 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
109
110 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
115 return f(self, *args, **kwargs)
116
117 @decorator
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
120
121 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
123 """
124
125 @web.authenticated
126 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
128
129 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
131 else:
132 return auth_f(self, *args, **kwargs)
133
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
137 Use to prevent double slash when joining subpath
138 """
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
142 #-----------------------------------------------------------------------------
143 # Top-level handlers
144 #-----------------------------------------------------------------------------
145
146 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
148
149 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
152
153 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
155
156 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
158
159 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
163 user_id = 'anonymous'
164 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
169 return user_id
170
171 @property
172 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
174
175 @property
176 def password(self):
177 """our password"""
178 return self.settings.get('password', '')
179
180 @property
181 def logged_in(self):
182 """Is a user currently logged in?
183
184 """
185 user = self.get_current_user()
186 return (user and not user == 'anonymous')
187
188 @property
189 def login_available(self):
190 """May a user proceed to log in?
191
192 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
194
195 """
196 return bool(self.settings.get('password', ''))
197
198 @property
199 def read_only(self):
200 """Is the notebook read-only?
201
202 """
203 return self.settings.get('read_only', False)
204
205
206 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
208
209 Mostly property shortcuts to IPython-specific settings.
210 """
211
212 @property
213 def config(self):
214 return self.settings.get('config', None)
215
216 @property
217 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
220 return Application.instance().log
221 else:
222 return app_log
223
224 @property
225 def use_less(self):
226 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
228
229 #---------------------------------------------------------------
230 # URLs
231 #---------------------------------------------------------------
232
233 @property
234 def ws_url(self):
235 """websocket url matching the current request
236
237 turns http[s]://host[:port] into
238 ws[s]://host[:port]
239 """
240 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
242 # default to config value
243 if host == '':
244 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
246
247 @property
248 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
250
251 @property
252 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
254
255 @property
256 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
258
259 #---------------------------------------------------------------
260 # Manager objects
261 #---------------------------------------------------------------
262
263 @property
264 def kernel_manager(self):
265 return self.settings['kernel_manager']
266
267 @property
268 def notebook_manager(self):
269 return self.settings['notebook_manager']
270
271 @property
272 def cluster_manager(self):
273 return self.settings['cluster_manager']
274
275 @property
276 def project(self):
277 return self.notebook_manager.notebook_dir
278
279 #---------------------------------------------------------------
280 # template rendering
281 #---------------------------------------------------------------
282
283 def get_template(self, name):
284 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
286
287 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
289 template = self.get_template(name)
290 return template.render(**ns)
291
292 @property
293 def template_namespace(self):
294 return dict(
295 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
298 logged_in=self.logged_in,
299 login_available=self.login_available,
300 use_less=self.use_less,
301 )
302
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
305
306 @authenticate_unless_readonly
307 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
309
310
311 class ProjectDashboardHandler(IPythonHandler):
312
313 @authenticate_unless_readonly
314 def get(self):
315 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
317 project_component=self.project.split('/'),
318 ))
319
320
321 class LoginHandler(IPythonHandler):
322
323 def _render(self, message=None):
324 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
327 ))
328
329 def get(self):
330 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
333 self._render()
334
335 def post(self):
336 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
338 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
341 self._render(message={'error': 'Invalid password'})
342 return
343
344 self.redirect(self.get_argument('next', default=self.base_project_url))
345
346
347 class LogoutHandler(IPythonHandler):
348
349 def get(self):
350 self.clear_login_cookie()
351 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
353 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
356 self.write(self.render_template('logout.html',
357 message=message))
358
359
360 class NewHandler(IPythonHandler):
361
362 @web.authenticated
363 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366
367 class NamedNotebookHandler(IPythonHandler):
368
369 @authenticate_unless_readonly
370 def get(self, notebook_id):
371 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
375 project=self.project,
376 notebook_id=notebook_id,
377 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
379 )
380 )
381
382
383 #-----------------------------------------------------------------------------
384 # Kernel handlers
385 #-----------------------------------------------------------------------------
386
387
388 class MainKernelHandler(IPythonHandler):
389
390 @web.authenticated
391 def get(self):
392 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
395 @web.authenticated
396 def post(self):
397 km = self.kernel_manager
398 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
404
405
406 class KernelHandler(IPythonHandler):
407
408 SUPPORTED_METHODS = ('DELETE')
409
410 @web.authenticated
411 def delete(self, kernel_id):
412 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
415 self.finish()
416
417
418 class KernelActionHandler(IPythonHandler):
419
420 @web.authenticated
421 def post(self, kernel_id, action):
422 km = self.kernel_manager
423 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
426 if action == 'restart':
427 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
431 self.finish()
432
433
434 class ZMQStreamHandler(websocket.WebSocketHandler):
435
436 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
438 pass
439
440 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
442
443 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
447 """
448 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
450 try:
451 msg['header'].pop('date')
452 except KeyError:
453 pass
454 try:
455 msg['parent_header'].pop('date')
456 except KeyError:
457 pass
458 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
460
461 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
464 if self.stream.closed(): return
465 try:
466 msg = self._reserialize_reply(msg_list)
467 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
470 self.write_message(msg)
471
472 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
475 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
477 """
478 return True
479
480
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
483 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
488
489 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
491 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
496 try:
497 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
499 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
502 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
507 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
513
514
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
517 @property
518 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
520
521 def create_stream(self):
522 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
526 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
528
529 def on_first_message(self, msg):
530 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
533 self.close()
534 return
535 try:
536 self.create_stream()
537 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
539 # close the connection.
540 if not self.stream.closed():
541 self.stream.close()
542 self.close()
543 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
545
546 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
550
551 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
558
559
560 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
562
563 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
569 def on_close(self):
570 km = self.kernel_manager
571 if self.kernel_id in km:
572 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
574 )
575 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
577 )
578 super(IOPubHandler, self).on_close()
579
580 def _send_status_message(self, status):
581 msg = self.session.msg("status",
582 {'execution_state': status}
583 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
585
586 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
589
590 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
593
594 def on_message(self, msg):
595 """IOPub messages make no sense"""
596 pass
597
598 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
600
601 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
603
24
25 from .base import IPythonHandler, authenticate_unless_readonly
604
26
605 #-----------------------------------------------------------------------------
27 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
28 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
29 #-----------------------------------------------------------------------------
608
30
609 class NotebookRedirectHandler(IPythonHandler):
610
611 @authenticate_unless_readonly
612 def get(self, notebook_name):
613 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
619 else:
620 raise HTTPError(404)
621
622
623 class NotebookRootHandler(IPythonHandler):
31 class NotebookRootHandler(IPythonHandler):
624
32
625 @authenticate_unless_readonly
33 @authenticate_unless_readonly
626 def get(self):
34 def get(self):
627 nbm = self.notebook_manager
35 nbm = self.notebook_manager
628 km = self.kernel_manager
36 km = self.kernel_manager
629 files = nbm.list_notebooks()
37 files = nbm.list_notebooks()
630 for f in files :
38 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
39 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
40 self.finish(jsonapi.dumps(files))
633
41
634 @web.authenticated
42 @web.authenticated
635 def post(self):
43 def post(self):
636 nbm = self.notebook_manager
44 nbm = self.notebook_manager
637 body = self.request.body.strip()
45 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
46 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
47 name = self.get_argument('name', default=None)
640 if body:
48 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
49 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
50 else:
643 notebook_id = nbm.new_notebook()
51 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
52 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
53 self.finish(jsonapi.dumps(notebook_id))
646
54
647
55
648 class NotebookHandler(IPythonHandler):
56 class NotebookHandler(IPythonHandler):
649
57
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
58 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
59
652 @authenticate_unless_readonly
60 @authenticate_unless_readonly
653 def get(self, notebook_id):
61 def get(self, notebook_id):
654 nbm = self.notebook_manager
62 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
63 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
64 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
65
658 if format == u'json':
66 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
67 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
68 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
69 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
70 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
71 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
72 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
73 self.finish(data)
666
74
667 @web.authenticated
75 @web.authenticated
668 def put(self, notebook_id):
76 def put(self, notebook_id):
669 nbm = self.notebook_manager
77 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
78 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
79 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
80 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
81 self.set_status(204)
674 self.finish()
82 self.finish()
675
83
676 @web.authenticated
84 @web.authenticated
677 def delete(self, notebook_id):
85 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
86 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
87 self.set_status(204)
680 self.finish()
88 self.finish()
681
89
682
90
683 class NotebookCheckpointsHandler(IPythonHandler):
91 class NotebookCheckpointsHandler(IPythonHandler):
684
92
685 SUPPORTED_METHODS = ('GET', 'POST')
93 SUPPORTED_METHODS = ('GET', 'POST')
686
94
687 @web.authenticated
95 @web.authenticated
688 def get(self, notebook_id):
96 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
97 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
98 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
99 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
100 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
101 self.finish(data)
694
102
695 @web.authenticated
103 @web.authenticated
696 def post(self, notebook_id):
104 def post(self, notebook_id):
697 """post creates a new checkpoint"""
105 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
106 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
107 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
108 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
109 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
110 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
111 ))
704
112
705 self.finish(data)
113 self.finish(data)
706
114
707
115
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
116 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
117
710 SUPPORTED_METHODS = ('POST', 'DELETE')
118 SUPPORTED_METHODS = ('POST', 'DELETE')
711
119
712 @web.authenticated
120 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
121 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
122 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
123 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
124 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
125 self.set_status(204)
718 self.finish()
126 self.finish()
719
127
720 @web.authenticated
128 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
129 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
130 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
131 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
132 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
133 self.set_status(204)
726 self.finish()
134 self.finish()
727
135
728
136
729 class NotebookCopyHandler(IPythonHandler):
730
731 @web.authenticated
732 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
735
736
737 #-----------------------------------------------------------------------------
738 # Cluster handlers
739 #-----------------------------------------------------------------------------
740
741
742 class MainClusterHandler(IPythonHandler):
743
744 @web.authenticated
745 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
748
749 class ClusterProfileHandler(IPythonHandler):
750
751 @web.authenticated
752 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
755
756 class ClusterActionHandler(IPythonHandler):
757
758 @web.authenticated
759 def post(self, profile, action):
760 cm = self.cluster_manager
761 if action == 'start':
762 n = self.get_argument('n',default=None)
763 if n is None:
764 data = cm.start_cluster(profile)
765 else:
766 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
768 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
137
931
138
This diff has been collapsed as it changes many lines, (906 lines changed) Show them Hide them
@@ -1,931 +1,33 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for the tree view.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 """
6 """
7
7
8 #-----------------------------------------------------------------------------
8 #-----------------------------------------------------------------------------
9 # Copyright (C) 2008-2011 The IPython Development Team
9 # Copyright (C) 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 import Cookie
19 from .base import IPythonHandler, authenticate_unless_readonly
20 import datetime
21 import email.utils
22 import hashlib
23 import logging
24 import mimetypes
25 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 from tornado import web
33 from tornado import websocket
34
35 try:
36 from tornado.log import app_log
37 except ImportError:
38 app_log = logging.getLogger()
39
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
42
43 from IPython.config import Application
44 from IPython.external.decorator import decorator
45 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 from IPython.utils.py3compat import PY3
50
51 try:
52 from docutils.core import publish_string
53 except ImportError:
54 publish_string = None
55
56 #-----------------------------------------------------------------------------
57 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
58 #-----------------------------------------------------------------------------
59
60 # Google Chrome, as of release 16, changed its websocket protocol number. The
61 # parts tornado cares about haven't really changed, so it's OK to continue
62 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
63 # version as of Oct 30/2011) the version check fails, see the issue report:
64
65 # https://github.com/facebook/tornado/issues/385
66
67 # This issue has been fixed in Tornado post 2.1.1:
68
69 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
70
71 # Here we manually apply the same patch as above so that users of IPython can
72 # continue to work with an officially released Tornado. We make the
73 # monkeypatch version check as narrow as possible to limit its effects; once
74 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
75
76 import tornado
77
78 if tornado.version_info <= (2,1,1):
79
80 def _execute(self, transforms, *args, **kwargs):
81 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
82
83 self.open_args = args
84 self.open_kwargs = kwargs
85
86 # The difference between version 8 and 13 is that in 8 the
87 # client sends a "Sec-Websocket-Origin" header and in 13 it's
88 # simply "Origin".
89 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
90 self.ws_connection = WebSocketProtocol8(self)
91 self.ws_connection.accept_connection()
92
93 elif self.request.headers.get("Sec-WebSocket-Version"):
94 self.stream.write(tornado.escape.utf8(
95 "HTTP/1.1 426 Upgrade Required\r\n"
96 "Sec-WebSocket-Version: 8\r\n\r\n"))
97 self.stream.close()
98
99 else:
100 self.ws_connection = WebSocketProtocol76(self)
101 self.ws_connection.accept_connection()
102
103 websocket.WebSocketHandler._execute = _execute
104 del _execute
105
106 #-----------------------------------------------------------------------------
107 # Decorator for disabling read-only handlers
108 #-----------------------------------------------------------------------------
109
110 @decorator
111 def not_if_readonly(f, self, *args, **kwargs):
112 if self.settings.get('read_only', False):
113 raise web.HTTPError(403, "Notebook server is read-only")
114 else:
115 return f(self, *args, **kwargs)
116
117 @decorator
118 def authenticate_unless_readonly(f, self, *args, **kwargs):
119 """authenticate this page *unless* readonly view is active.
120
121 In read-only mode, the notebook list and print view should
122 be accessible without authentication.
123 """
124
125 @web.authenticated
126 def auth_f(self, *args, **kwargs):
127 return f(self, *args, **kwargs)
128
129 if self.settings.get('read_only', False):
130 return f(self, *args, **kwargs)
131 else:
132 return auth_f(self, *args, **kwargs)
133
134 def urljoin(*pieces):
135 """Join components of url into a relative url
136
137 Use to prevent double slash when joining subpath
138 """
139 striped = [s.strip('/') for s in pieces]
140 return '/'.join(s for s in striped if s)
141
20
142 #-----------------------------------------------------------------------------
21 #-----------------------------------------------------------------------------
143 # Top-level handlers
22 # Handlers
144 #-----------------------------------------------------------------------------
23 #-----------------------------------------------------------------------------
145
24
146 class RequestHandler(web.RequestHandler):
147 """RequestHandler with default variable setting."""
148
149 def render(*args, **kwargs):
150 kwargs.setdefault('message', '')
151 return web.RequestHandler.render(*args, **kwargs)
152
153 class AuthenticatedHandler(RequestHandler):
154 """A RequestHandler with an authenticated user."""
155
156 def clear_login_cookie(self):
157 self.clear_cookie(self.cookie_name)
158
159 def get_current_user(self):
160 user_id = self.get_secure_cookie(self.cookie_name)
161 # For now the user_id should not return empty, but it could eventually
162 if user_id == '':
163 user_id = 'anonymous'
164 if user_id is None:
165 # prevent extra Invalid cookie sig warnings:
166 self.clear_login_cookie()
167 if not self.read_only and not self.login_available:
168 user_id = 'anonymous'
169 return user_id
170
171 @property
172 def cookie_name(self):
173 return self.settings.get('cookie_name', '')
174
175 @property
176 def password(self):
177 """our password"""
178 return self.settings.get('password', '')
179
180 @property
181 def logged_in(self):
182 """Is a user currently logged in?
183
184 """
185 user = self.get_current_user()
186 return (user and not user == 'anonymous')
187
188 @property
189 def login_available(self):
190 """May a user proceed to log in?
191
192 This returns True if login capability is available, irrespective of
193 whether the user is already logged in or not.
194
195 """
196 return bool(self.settings.get('password', ''))
197
198 @property
199 def read_only(self):
200 """Is the notebook read-only?
201
202 """
203 return self.settings.get('read_only', False)
204
205
206 class IPythonHandler(AuthenticatedHandler):
207 """IPython-specific extensions to authenticated handling
208
209 Mostly property shortcuts to IPython-specific settings.
210 """
211
212 @property
213 def config(self):
214 return self.settings.get('config', None)
215
216 @property
217 def log(self):
218 """use the IPython log by default, falling back on tornado's logger"""
219 if Application.initialized():
220 return Application.instance().log
221 else:
222 return app_log
223
224 @property
225 def use_less(self):
226 """Use less instead of css in templates"""
227 return self.settings.get('use_less', False)
228
229 #---------------------------------------------------------------
230 # URLs
231 #---------------------------------------------------------------
232
233 @property
234 def ws_url(self):
235 """websocket url matching the current request
236
237 turns http[s]://host[:port] into
238 ws[s]://host[:port]
239 """
240 proto = self.request.protocol.replace('http', 'ws')
241 host = self.settings.get('websocket_host', '')
242 # default to config value
243 if host == '':
244 host = self.request.host # get from request
245 return "%s://%s" % (proto, host)
246
247 @property
248 def mathjax_url(self):
249 return self.settings.get('mathjax_url', '')
250
251 @property
252 def base_project_url(self):
253 return self.settings.get('base_project_url', '/')
254
255 @property
256 def base_kernel_url(self):
257 return self.settings.get('base_kernel_url', '/')
258
259 #---------------------------------------------------------------
260 # Manager objects
261 #---------------------------------------------------------------
262
263 @property
264 def kernel_manager(self):
265 return self.settings['kernel_manager']
266
267 @property
268 def notebook_manager(self):
269 return self.settings['notebook_manager']
270
271 @property
272 def cluster_manager(self):
273 return self.settings['cluster_manager']
274
275 @property
276 def project(self):
277 return self.notebook_manager.notebook_dir
278
279 #---------------------------------------------------------------
280 # template rendering
281 #---------------------------------------------------------------
282
283 def get_template(self, name):
284 """Return the jinja template object for a given name"""
285 return self.settings['jinja2_env'].get_template(name)
286
287 def render_template(self, name, **ns):
288 ns.update(self.template_namespace)
289 template = self.get_template(name)
290 return template.render(**ns)
291
292 @property
293 def template_namespace(self):
294 return dict(
295 base_project_url=self.base_project_url,
296 base_kernel_url=self.base_kernel_url,
297 read_only=self.read_only,
298 logged_in=self.logged_in,
299 login_available=self.login_available,
300 use_less=self.use_less,
301 )
302
303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
304 """static files should only be accessible when logged in"""
305
306 @authenticate_unless_readonly
307 def get(self, path):
308 return web.StaticFileHandler.get(self, path)
309
310
25
311 class ProjectDashboardHandler(IPythonHandler):
26 class ProjectDashboardHandler(IPythonHandler):
312
27
313 @authenticate_unless_readonly
28 @authenticate_unless_readonly
314 def get(self):
29 def get(self):
315 self.write(self.render_template('projectdashboard.html',
30 self.write(self.render_template('projectdashboard.html',
316 project=self.project,
31 project=self.project,
317 project_component=self.project.split('/'),
32 project_component=self.project.split('/'),
318 ))
33 ))
319
320
321 class LoginHandler(IPythonHandler):
322
323 def _render(self, message=None):
324 self.write(self.render_template('login.html',
325 next=url_escape(self.get_argument('next', default=self.base_project_url)),
326 message=message,
327 ))
328
329 def get(self):
330 if self.current_user:
331 self.redirect(self.get_argument('next', default=self.base_project_url))
332 else:
333 self._render()
334
335 def post(self):
336 pwd = self.get_argument('password', default=u'')
337 if self.login_available:
338 if passwd_check(self.password, pwd):
339 self.set_secure_cookie(self.cookie_name, str(uuid.uuid4()))
340 else:
341 self._render(message={'error': 'Invalid password'})
342 return
343
344 self.redirect(self.get_argument('next', default=self.base_project_url))
345
346
347 class LogoutHandler(IPythonHandler):
348
349 def get(self):
350 self.clear_login_cookie()
351 if self.login_available:
352 message = {'info': 'Successfully logged out.'}
353 else:
354 message = {'warning': 'Cannot log out. Notebook authentication '
355 'is disabled.'}
356 self.write(self.render_template('logout.html',
357 message=message))
358
359
360 class NewHandler(IPythonHandler):
361
362 @web.authenticated
363 def get(self):
364 notebook_id = self.notebook_manager.new_notebook()
365 self.redirect('/' + urljoin(self.base_project_url, notebook_id))
366
367 class NamedNotebookHandler(IPythonHandler):
368
369 @authenticate_unless_readonly
370 def get(self, notebook_id):
371 nbm = self.notebook_manager
372 if not nbm.notebook_exists(notebook_id):
373 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
374 self.write(self.render_template('notebook.html',
375 project=self.project,
376 notebook_id=notebook_id,
377 kill_kernel=False,
378 mathjax_url=self.mathjax_url,
379 )
380 )
381
382
383 #-----------------------------------------------------------------------------
384 # Kernel handlers
385 #-----------------------------------------------------------------------------
386
387
388 class MainKernelHandler(IPythonHandler):
389
390 @web.authenticated
391 def get(self):
392 km = self.kernel_manager
393 self.finish(jsonapi.dumps(km.list_kernel_ids()))
394
395 @web.authenticated
396 def post(self):
397 km = self.kernel_manager
398 nbm = self.notebook_manager
399 notebook_id = self.get_argument('notebook', default=None)
400 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
401 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
402 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
403 self.finish(jsonapi.dumps(data))
404
405
406 class KernelHandler(IPythonHandler):
407
408 SUPPORTED_METHODS = ('DELETE')
409
410 @web.authenticated
411 def delete(self, kernel_id):
412 km = self.kernel_manager
413 km.shutdown_kernel(kernel_id)
414 self.set_status(204)
415 self.finish()
416
417
418 class KernelActionHandler(IPythonHandler):
419
420 @web.authenticated
421 def post(self, kernel_id, action):
422 km = self.kernel_manager
423 if action == 'interrupt':
424 km.interrupt_kernel(kernel_id)
425 self.set_status(204)
426 if action == 'restart':
427 km.restart_kernel(kernel_id)
428 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
429 self.set_header('Location', '{0}kernels/{1}'.format(self.base_kernel_url, kernel_id))
430 self.write(jsonapi.dumps(data))
431 self.finish()
432
433
434 class ZMQStreamHandler(websocket.WebSocketHandler):
435
436 def clear_cookie(self, *args, **kwargs):
437 """meaningless for websockets"""
438 pass
439
440 def _reserialize_reply(self, msg_list):
441 """Reserialize a reply message using JSON.
442
443 This takes the msg list from the ZMQ socket, unserializes it using
444 self.session and then serializes the result using JSON. This method
445 should be used by self._on_zmq_reply to build messages that can
446 be sent back to the browser.
447 """
448 idents, msg_list = self.session.feed_identities(msg_list)
449 msg = self.session.unserialize(msg_list)
450 try:
451 msg['header'].pop('date')
452 except KeyError:
453 pass
454 try:
455 msg['parent_header'].pop('date')
456 except KeyError:
457 pass
458 msg.pop('buffers')
459 return jsonapi.dumps(msg, default=date_default)
460
461 def _on_zmq_reply(self, msg_list):
462 # Sometimes this gets triggered when the on_close method is scheduled in the
463 # eventloop but hasn't been called.
464 if self.stream.closed(): return
465 try:
466 msg = self._reserialize_reply(msg_list)
467 except Exception:
468 self.log.critical("Malformed message: %r" % msg_list, exc_info=True)
469 else:
470 self.write_message(msg)
471
472 def allow_draft76(self):
473 """Allow draft 76, until browsers such as Safari update to RFC 6455.
474
475 This has been disabled by default in tornado in release 2.2.0, and
476 support will be removed in later versions.
477 """
478 return True
479
480
481 class AuthenticatedZMQStreamHandler(ZMQStreamHandler, IPythonHandler):
482
483 def open(self, kernel_id):
484 self.kernel_id = kernel_id.decode('ascii')
485 self.session = Session(config=self.config)
486 self.save_on_message = self.on_message
487 self.on_message = self.on_first_message
488
489 def _inject_cookie_message(self, msg):
490 """Inject the first message, which is the document cookie,
491 for authentication."""
492 if not PY3 and isinstance(msg, unicode):
493 # Cookie constructor doesn't accept unicode strings
494 # under Python 2.x for some reason
495 msg = msg.encode('utf8', 'replace')
496 try:
497 identity, msg = msg.split(':', 1)
498 self.session.session = identity.decode('ascii')
499 except Exception:
500 logging.error("First ws message didn't have the form 'identity:[cookie]' - %r", msg)
501
502 try:
503 self.request._cookies = Cookie.SimpleCookie(msg)
504 except:
505 self.log.warn("couldn't parse cookie string: %s",msg, exc_info=True)
506
507 def on_first_message(self, msg):
508 self._inject_cookie_message(msg)
509 if self.get_current_user() is None:
510 self.log.warn("Couldn't authenticate WebSocket connection")
511 raise web.HTTPError(403)
512 self.on_message = self.save_on_message
513
514
515 class ZMQChannelHandler(AuthenticatedZMQStreamHandler):
516
517 @property
518 def max_msg_size(self):
519 return self.settings.get('max_msg_size', 65535)
520
521 def create_stream(self):
522 km = self.kernel_manager
523 meth = getattr(km, 'connect_%s' % self.channel)
524 self.zmq_stream = meth(self.kernel_id, identity=self.session.bsession)
525
526 def initialize(self, *args, **kwargs):
527 self.zmq_stream = None
528
529 def on_first_message(self, msg):
530 try:
531 super(ZMQChannelHandler, self).on_first_message(msg)
532 except web.HTTPError:
533 self.close()
534 return
535 try:
536 self.create_stream()
537 except web.HTTPError:
538 # WebSockets don't response to traditional error codes so we
539 # close the connection.
540 if not self.stream.closed():
541 self.stream.close()
542 self.close()
543 else:
544 self.zmq_stream.on_recv(self._on_zmq_reply)
545
546 def on_message(self, msg):
547 if len(msg) < self.max_msg_size:
548 msg = jsonapi.loads(msg)
549 self.session.send(self.zmq_stream, msg)
550
551 def on_close(self):
552 # This method can be called twice, once by self.kernel_died and once
553 # from the WebSocket close event. If the WebSocket connection is
554 # closed before the ZMQ streams are setup, they could be None.
555 if self.zmq_stream is not None and not self.zmq_stream.closed():
556 self.zmq_stream.on_recv(None)
557 self.zmq_stream.close()
558
559
560 class IOPubHandler(ZMQChannelHandler):
561 channel = 'iopub'
562
563 def create_stream(self):
564 super(IOPubHandler, self).create_stream()
565 km = self.kernel_manager
566 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
567 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
568
569 def on_close(self):
570 km = self.kernel_manager
571 if self.kernel_id in km:
572 km.remove_restart_callback(
573 self.kernel_id, self.on_kernel_restarted,
574 )
575 km.remove_restart_callback(
576 self.kernel_id, self.on_restart_failed, 'dead',
577 )
578 super(IOPubHandler, self).on_close()
579
580 def _send_status_message(self, status):
581 msg = self.session.msg("status",
582 {'execution_state': status}
583 )
584 self.write_message(jsonapi.dumps(msg, default=date_default))
585
586 def on_kernel_restarted(self):
587 logging.warn("kernel %s restarted", self.kernel_id)
588 self._send_status_message('restarting')
589
590 def on_restart_failed(self):
591 logging.error("kernel %s restarted failed!", self.kernel_id)
592 self._send_status_message('dead')
593
594 def on_message(self, msg):
595 """IOPub messages make no sense"""
596 pass
597
598 class ShellHandler(ZMQChannelHandler):
599 channel = 'shell'
600
601 class StdinHandler(ZMQChannelHandler):
602 channel = 'stdin'
603
604
605 #-----------------------------------------------------------------------------
606 # Notebook web service handlers
607 #-----------------------------------------------------------------------------
608
609 class NotebookRedirectHandler(IPythonHandler):
610
611 @authenticate_unless_readonly
612 def get(self, notebook_name):
613 # strip trailing .ipynb:
614 notebook_name = os.path.splitext(notebook_name)[0]
615 notebook_id = self.notebook_manager.rev_mapping.get(notebook_name, '')
616 if notebook_id:
617 url = self.settings.get('base_project_url', '/') + notebook_id
618 return self.redirect(url)
619 else:
620 raise HTTPError(404)
621
622
623 class NotebookRootHandler(IPythonHandler):
624
625 @authenticate_unless_readonly
626 def get(self):
627 nbm = self.notebook_manager
628 km = self.kernel_manager
629 files = nbm.list_notebooks()
630 for f in files :
631 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
632 self.finish(jsonapi.dumps(files))
633
634 @web.authenticated
635 def post(self):
636 nbm = self.notebook_manager
637 body = self.request.body.strip()
638 format = self.get_argument('format', default='json')
639 name = self.get_argument('name', default=None)
640 if body:
641 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
642 else:
643 notebook_id = nbm.new_notebook()
644 self.set_header('Location', '{0}notebooks/{1}'.format(self.base_project_url, notebook_id))
645 self.finish(jsonapi.dumps(notebook_id))
646
647
648 class NotebookHandler(IPythonHandler):
649
650 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
651
652 @authenticate_unless_readonly
653 def get(self, notebook_id):
654 nbm = self.notebook_manager
655 format = self.get_argument('format', default='json')
656 last_mod, name, data = nbm.get_notebook(notebook_id, format)
657
658 if format == u'json':
659 self.set_header('Content-Type', 'application/json')
660 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
661 elif format == u'py':
662 self.set_header('Content-Type', 'application/x-python')
663 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
664 self.set_header('Last-Modified', last_mod)
665 self.finish(data)
666
667 @web.authenticated
668 def put(self, notebook_id):
669 nbm = self.notebook_manager
670 format = self.get_argument('format', default='json')
671 name = self.get_argument('name', default=None)
672 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
673 self.set_status(204)
674 self.finish()
675
676 @web.authenticated
677 def delete(self, notebook_id):
678 self.notebook_manager.delete_notebook(notebook_id)
679 self.set_status(204)
680 self.finish()
681
682
683 class NotebookCheckpointsHandler(IPythonHandler):
684
685 SUPPORTED_METHODS = ('GET', 'POST')
686
687 @web.authenticated
688 def get(self, notebook_id):
689 """get lists checkpoints for a notebook"""
690 nbm = self.notebook_manager
691 checkpoints = nbm.list_checkpoints(notebook_id)
692 data = jsonapi.dumps(checkpoints, default=date_default)
693 self.finish(data)
694
695 @web.authenticated
696 def post(self, notebook_id):
697 """post creates a new checkpoint"""
698 nbm = self.notebook_manager
699 checkpoint = nbm.create_checkpoint(notebook_id)
700 data = jsonapi.dumps(checkpoint, default=date_default)
701 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
702 self.base_project_url, notebook_id, checkpoint['checkpoint_id']
703 ))
704
705 self.finish(data)
706
707
708 class ModifyNotebookCheckpointsHandler(IPythonHandler):
709
710 SUPPORTED_METHODS = ('POST', 'DELETE')
711
712 @web.authenticated
713 def post(self, notebook_id, checkpoint_id):
714 """post restores a notebook from a checkpoint"""
715 nbm = self.notebook_manager
716 nbm.restore_checkpoint(notebook_id, checkpoint_id)
717 self.set_status(204)
718 self.finish()
719
720 @web.authenticated
721 def delete(self, notebook_id, checkpoint_id):
722 """delete clears a checkpoint for a given notebook"""
723 nbm = self.notebook_manager
724 nbm.delte_checkpoint(notebook_id, checkpoint_id)
725 self.set_status(204)
726 self.finish()
727
728
729 class NotebookCopyHandler(IPythonHandler):
730
731 @web.authenticated
732 def get(self, notebook_id):
733 notebook_id = self.notebook_manager.copy_notebook(notebook_id)
734 self.redirect('/'+urljoin(self.base_project_url, notebook_id))
735
736
737 #-----------------------------------------------------------------------------
738 # Cluster handlers
739 #-----------------------------------------------------------------------------
740
741
742 class MainClusterHandler(IPythonHandler):
743
744 @web.authenticated
745 def get(self):
746 self.finish(jsonapi.dumps(self.cluster_manager.list_profiles()))
747
748
749 class ClusterProfileHandler(IPythonHandler):
750
751 @web.authenticated
752 def get(self, profile):
753 self.finish(jsonapi.dumps(self.cluster_manager.profile_info(profile)))
754
755
756 class ClusterActionHandler(IPythonHandler):
757
758 @web.authenticated
759 def post(self, profile, action):
760 cm = self.cluster_manager
761 if action == 'start':
762 n = self.get_argument('n',default=None)
763 if n is None:
764 data = cm.start_cluster(profile)
765 else:
766 data = cm.start_cluster(profile, int(n))
767 if action == 'stop':
768 data = cm.stop_cluster(profile)
769 self.finish(jsonapi.dumps(data))
770
771
772 #-----------------------------------------------------------------------------
773 # File handler
774 #-----------------------------------------------------------------------------
775
776 # to minimize subclass changes:
777 HTTPError = web.HTTPError
778
779 class FileFindHandler(web.StaticFileHandler):
780 """subclass of StaticFileHandler for serving files from a search path"""
781
782 _static_paths = {}
783 # _lock is needed for tornado < 2.2.0 compat
784 _lock = threading.Lock() # protects _static_hashes
785
786 def initialize(self, path, default_filename=None):
787 if isinstance(path, basestring):
788 path = [path]
789 self.roots = tuple(
790 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
791 )
792 self.default_filename = default_filename
793
794 @classmethod
795 def locate_file(cls, path, roots):
796 """locate a file to serve on our static file search path"""
797 with cls._lock:
798 if path in cls._static_paths:
799 return cls._static_paths[path]
800 try:
801 abspath = os.path.abspath(filefind(path, roots))
802 except IOError:
803 # empty string should always give exists=False
804 return ''
805
806 # os.path.abspath strips a trailing /
807 # it needs to be temporarily added back for requests to root/
808 if not (abspath + os.path.sep).startswith(roots):
809 raise HTTPError(403, "%s is not in root static directory", path)
810
811 cls._static_paths[path] = abspath
812 return abspath
813
814 def get(self, path, include_body=True):
815 path = self.parse_url_path(path)
816
817 # begin subclass override
818 abspath = self.locate_file(path, self.roots)
819 # end subclass override
820
821 if os.path.isdir(abspath) and self.default_filename is not None:
822 # need to look at the request.path here for when path is empty
823 # but there is some prefix to the path that was already
824 # trimmed by the routing
825 if not self.request.path.endswith("/"):
826 self.redirect(self.request.path + "/")
827 return
828 abspath = os.path.join(abspath, self.default_filename)
829 if not os.path.exists(abspath):
830 raise HTTPError(404)
831 if not os.path.isfile(abspath):
832 raise HTTPError(403, "%s is not a file", path)
833
834 stat_result = os.stat(abspath)
835 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
836
837 self.set_header("Last-Modified", modified)
838
839 mime_type, encoding = mimetypes.guess_type(abspath)
840 if mime_type:
841 self.set_header("Content-Type", mime_type)
842
843 cache_time = self.get_cache_time(path, modified, mime_type)
844
845 if cache_time > 0:
846 self.set_header("Expires", datetime.datetime.utcnow() + \
847 datetime.timedelta(seconds=cache_time))
848 self.set_header("Cache-Control", "max-age=" + str(cache_time))
849 else:
850 self.set_header("Cache-Control", "public")
851
852 self.set_extra_headers(path)
853
854 # Check the If-Modified-Since, and don't send the result if the
855 # content has not been modified
856 ims_value = self.request.headers.get("If-Modified-Since")
857 if ims_value is not None:
858 date_tuple = email.utils.parsedate(ims_value)
859 if_since = datetime.datetime(*date_tuple[:6])
860 if if_since >= modified:
861 self.set_status(304)
862 return
863
864 with open(abspath, "rb") as file:
865 data = file.read()
866 hasher = hashlib.sha1()
867 hasher.update(data)
868 self.set_header("Etag", '"%s"' % hasher.hexdigest())
869 if include_body:
870 self.write(data)
871 else:
872 assert self.request.method == "HEAD"
873 self.set_header("Content-Length", len(data))
874
875 @classmethod
876 def get_version(cls, settings, path):
877 """Generate the version string to be used in static URLs.
878
879 This method may be overridden in subclasses (but note that it
880 is a class method rather than a static method). The default
881 implementation uses a hash of the file's contents.
882
883 ``settings`` is the `Application.settings` dictionary and ``path``
884 is the relative location of the requested asset on the filesystem.
885 The returned value should be a string, or ``None`` if no version
886 could be determined.
887 """
888 # begin subclass override:
889 static_paths = settings['static_path']
890 if isinstance(static_paths, basestring):
891 static_paths = [static_paths]
892 roots = tuple(
893 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
894 )
895
896 try:
897 abs_path = filefind(path, roots)
898 except IOError:
899 app_log.error("Could not find static file %r", path)
900 return None
901
902 # end subclass override
903
904 with cls._lock:
905 hashes = cls._static_hashes
906 if abs_path not in hashes:
907 try:
908 f = open(abs_path, "rb")
909 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
910 f.close()
911 except Exception:
912 app_log.error("Could not open static file %r", path)
913 hashes[abs_path] = None
914 hsh = hashes.get(abs_path)
915 if hsh:
916 return hsh[:5]
917 return None
918
919
920 def parse_url_path(self, url_path):
921 """Converts a static URL path into a filesystem path.
922
923 ``url_path`` is the path component of the URL with
924 ``static_url_prefix`` removed. The return value should be
925 filesystem path relative to ``static_path``.
926 """
927 if os.path.sep != "/":
928 url_path = url_path.replace("/", os.path.sep)
929 return url_path
930
931
@@ -1,754 +1,761 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) 2013 The IPython Development Team
9 # Copyright (C) 2013 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
25 import select
24 import select
26 import signal
25 import signal
27 import socket
26 import socket
28 import sys
27 import sys
29 import threading
28 import threading
30 import time
29 import time
31 import uuid
30 import uuid
32 import webbrowser
31 import webbrowser
33
32
34
33
35 # Third party
34 # Third party
36 # check for pyzmq 2.1.11
35 # check for pyzmq 2.1.11
37 from IPython.utils.zmqrelated import check_for_zmq
36 from IPython.utils.zmqrelated import check_for_zmq
38 check_for_zmq('2.1.11', 'IPython.frontend.html.notebook')
37 check_for_zmq('2.1.11', 'IPython.frontend.html.notebook')
39
38
40 import zmq
39 import zmq
41 from jinja2 import Environment, FileSystemLoader
40 from jinja2 import Environment, FileSystemLoader
42
41
43 # Install the pyzmq ioloop. This has to be done before anything else from
42 # Install the pyzmq ioloop. This has to be done before anything else from
44 # tornado is imported.
43 # tornado is imported.
45 from zmq.eventloop import ioloop
44 from zmq.eventloop import ioloop
46 ioloop.install()
45 ioloop.install()
47
46
48 # check for tornado 2.1.0
47 # check for tornado 2.1.0
49 msg = "The IPython Notebook requires tornado >= 2.1.0"
48 msg = "The IPython Notebook requires tornado >= 2.1.0"
50 try:
49 try:
51 import tornado
50 import tornado
52 except ImportError:
51 except ImportError:
53 raise ImportError(msg)
52 raise ImportError(msg)
54 try:
53 try:
55 version_info = tornado.version_info
54 version_info = tornado.version_info
56 except AttributeError:
55 except AttributeError:
57 raise ImportError(msg + ", but you have < 1.1.0")
56 raise ImportError(msg + ", but you have < 1.1.0")
58 if version_info < (2,1,0):
57 if version_info < (2,1,0):
59 raise ImportError(msg + ", but you have %s" % tornado.version)
58 raise ImportError(msg + ", but you have %s" % tornado.version)
60
59
61 from tornado import httpserver
60 from tornado import httpserver
62 from tornado import web
61 from tornado import web
63
62
64 # Our own libraries
63 # Our own libraries
65 from IPython.frontend.html.notebook import DEFAULT_STATIC_FILES_PATH
64 from IPython.frontend.html.notebook import DEFAULT_STATIC_FILES_PATH
66 from .kernelmanager import MappingKernelManager
65 from .kernelmanager import MappingKernelManager
67 from .handlers import (LoginHandler, LogoutHandler,
66
68 ProjectDashboardHandler, NewHandler, NamedNotebookHandler,
67 from .handlers.clustersapi import (
69 MainKernelHandler, KernelHandler, KernelActionHandler, IOPubHandler, StdinHandler,
68 MainClusterHandler, ClusterProfileHandler, ClusterActionHandler
70 ShellHandler, NotebookRootHandler, NotebookHandler, NotebookCopyHandler,
69 )
71 NotebookRedirectHandler, NotebookCheckpointsHandler, ModifyNotebookCheckpointsHandler,
70 from .handlers.kernelsapi import (
72 AuthenticatedFileHandler, FileFindHandler,
71 MainKernelHandler, KernelHandler, KernelActionHandler,
73 MainClusterHandler, ClusterProfileHandler, ClusterActionHandler,
72 IOPubHandler, StdinHandler, ShellHandler
73 )
74 from .handlers.notebooksapi import (
75 NotebookRootHandler, NotebookHandler,
76 NotebookCheckpointsHandler, ModifyNotebookCheckpointsHandler
77 )
78 from .handlers.tree import ProjectDashboardHandler
79 from .handlers.login import LoginHandler
80 from .handlers.logout import LogoutHandler
81 from .handlers.notebooks import (
82 NewHandler, NamedNotebookHandler,
83 NotebookCopyHandler, NotebookRedirectHandler
74 )
84 )
85
86 from .handlers.base import AuthenticatedFileHandler
87 from .handlers.files import FileFindHandler
88
75 from .nbmanager import NotebookManager
89 from .nbmanager import NotebookManager
76 from .filenbmanager import FileNotebookManager
90 from .filenbmanager import FileNotebookManager
77 from .clustermanager import ClusterManager
91 from .clustermanager import ClusterManager
78
92
79 from IPython.config.application import catch_config_error, boolean_flag
93 from IPython.config.application import catch_config_error, boolean_flag
80 from IPython.core.application import BaseIPythonApplication
94 from IPython.core.application import BaseIPythonApplication
81 from IPython.core.profiledir import ProfileDir
82 from IPython.frontend.consoleapp import IPythonConsoleApp
95 from IPython.frontend.consoleapp import IPythonConsoleApp
83 from IPython.kernel import swallow_argv
96 from IPython.kernel import swallow_argv
84 from IPython.kernel.zmq.session import Session, default_secure
97 from IPython.kernel.zmq.session import default_secure
85 from IPython.kernel.zmq.zmqshell import ZMQInteractiveShell
86 from IPython.kernel.zmq.kernelapp import (
98 from IPython.kernel.zmq.kernelapp import (
87 kernel_flags,
99 kernel_flags,
88 kernel_aliases,
100 kernel_aliases,
89 IPKernelApp
90 )
101 )
91 from IPython.utils.importstring import import_item
102 from IPython.utils.importstring import import_item
92 from IPython.utils.localinterfaces import LOCALHOST
103 from IPython.utils.localinterfaces import LOCALHOST
93 from IPython.utils import submodule
104 from IPython.utils import submodule
94 from IPython.utils.traitlets import (
105 from IPython.utils.traitlets import (
95 Dict, Unicode, Integer, List, Enum, Bool,
106 Dict, Unicode, Integer, List, Bool,
96 DottedObjectName
107 DottedObjectName
97 )
108 )
98 from IPython.utils import py3compat
109 from IPython.utils import py3compat
99 from IPython.utils.path import filefind
110 from IPython.utils.path import filefind
100
111
112 from .utils import url_path_join
113
101 #-----------------------------------------------------------------------------
114 #-----------------------------------------------------------------------------
102 # Module globals
115 # Module globals
103 #-----------------------------------------------------------------------------
116 #-----------------------------------------------------------------------------
104
117
105 _kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"
118 _kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"
106 _kernel_action_regex = r"(?P<action>restart|interrupt)"
119 _kernel_action_regex = r"(?P<action>restart|interrupt)"
107 _notebook_id_regex = r"(?P<notebook_id>\w+-\w+-\w+-\w+-\w+)"
120 _notebook_id_regex = r"(?P<notebook_id>\w+-\w+-\w+-\w+-\w+)"
108 _notebook_name_regex = r"(?P<notebook_name>.+\.ipynb)"
121 _notebook_name_regex = r"(?P<notebook_name>.+\.ipynb)"
109 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
122 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
110 _profile_regex = r"(?P<profile>[^\/]+)" # there is almost no text that is invalid
123 _profile_regex = r"(?P<profile>[^\/]+)" # there is almost no text that is invalid
111 _cluster_action_regex = r"(?P<action>start|stop)"
124 _cluster_action_regex = r"(?P<action>start|stop)"
112
125
113 _examples = """
126 _examples = """
114 ipython notebook # start the notebook
127 ipython notebook # start the notebook
115 ipython notebook --profile=sympy # use the sympy profile
128 ipython notebook --profile=sympy # use the sympy profile
116 ipython notebook --pylab=inline # pylab in inline plotting mode
129 ipython notebook --pylab=inline # pylab in inline plotting mode
117 ipython notebook --certfile=mycert.pem # use SSL/TLS certificate
130 ipython notebook --certfile=mycert.pem # use SSL/TLS certificate
118 ipython notebook --port=5555 --ip=* # Listen on port 5555, all interfaces
131 ipython notebook --port=5555 --ip=* # Listen on port 5555, all interfaces
119 """
132 """
120
133
121 #-----------------------------------------------------------------------------
134 #-----------------------------------------------------------------------------
122 # Helper functions
135 # Helper functions
123 #-----------------------------------------------------------------------------
136 #-----------------------------------------------------------------------------
124
137
125 def url_path_join(a,b):
126 if a.endswith('/') and b.startswith('/'):
127 return a[:-1]+b
128 else:
129 return a+b
130
131 def random_ports(port, n):
138 def random_ports(port, n):
132 """Generate a list of n random ports near the given port.
139 """Generate a list of n random ports near the given port.
133
140
134 The first 5 ports will be sequential, and the remaining n-5 will be
141 The first 5 ports will be sequential, and the remaining n-5 will be
135 randomly selected in the range [port-2*n, port+2*n].
142 randomly selected in the range [port-2*n, port+2*n].
136 """
143 """
137 for i in range(min(5, n)):
144 for i in range(min(5, n)):
138 yield port + i
145 yield port + i
139 for i in range(n-5):
146 for i in range(n-5):
140 yield port + random.randint(-2*n, 2*n)
147 yield port + random.randint(-2*n, 2*n)
141
148
142 #-----------------------------------------------------------------------------
149 #-----------------------------------------------------------------------------
143 # The Tornado web application
150 # The Tornado web application
144 #-----------------------------------------------------------------------------
151 #-----------------------------------------------------------------------------
145
152
146 class NotebookWebApplication(web.Application):
153 class NotebookWebApplication(web.Application):
147
154
148 def __init__(self, ipython_app, kernel_manager, notebook_manager,
155 def __init__(self, ipython_app, kernel_manager, notebook_manager,
149 cluster_manager, log,
156 cluster_manager, log,
150 base_project_url, settings_overrides):
157 base_project_url, settings_overrides):
151 handlers = [
158 handlers = [
152 (r"/", ProjectDashboardHandler),
159 (r"/", ProjectDashboardHandler),
153 (r"/login", LoginHandler),
160 (r"/login", LoginHandler),
154 (r"/logout", LogoutHandler),
161 (r"/logout", LogoutHandler),
155 (r"/new", NewHandler),
162 (r"/new", NewHandler),
156 (r"/%s" % _notebook_id_regex, NamedNotebookHandler),
163 (r"/%s" % _notebook_id_regex, NamedNotebookHandler),
157 (r"/%s" % _notebook_name_regex, NotebookRedirectHandler),
164 (r"/%s" % _notebook_name_regex, NotebookRedirectHandler),
158 (r"/%s/copy" % _notebook_id_regex, NotebookCopyHandler),
165 (r"/%s/copy" % _notebook_id_regex, NotebookCopyHandler),
159 (r"/kernels", MainKernelHandler),
166 (r"/kernels", MainKernelHandler),
160 (r"/kernels/%s" % _kernel_id_regex, KernelHandler),
167 (r"/kernels/%s" % _kernel_id_regex, KernelHandler),
161 (r"/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler),
168 (r"/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler),
162 (r"/kernels/%s/iopub" % _kernel_id_regex, IOPubHandler),
169 (r"/kernels/%s/iopub" % _kernel_id_regex, IOPubHandler),
163 (r"/kernels/%s/shell" % _kernel_id_regex, ShellHandler),
170 (r"/kernels/%s/shell" % _kernel_id_regex, ShellHandler),
164 (r"/kernels/%s/stdin" % _kernel_id_regex, StdinHandler),
171 (r"/kernels/%s/stdin" % _kernel_id_regex, StdinHandler),
165 (r"/notebooks", NotebookRootHandler),
172 (r"/notebooks", NotebookRootHandler),
166 (r"/notebooks/%s" % _notebook_id_regex, NotebookHandler),
173 (r"/notebooks/%s" % _notebook_id_regex, NotebookHandler),
167 (r"/notebooks/%s/checkpoints" % _notebook_id_regex, NotebookCheckpointsHandler),
174 (r"/notebooks/%s/checkpoints" % _notebook_id_regex, NotebookCheckpointsHandler),
168 (r"/notebooks/%s/checkpoints/%s" % (_notebook_id_regex, _checkpoint_id_regex),
175 (r"/notebooks/%s/checkpoints/%s" % (_notebook_id_regex, _checkpoint_id_regex),
169 ModifyNotebookCheckpointsHandler
176 ModifyNotebookCheckpointsHandler
170 ),
177 ),
171 (r"/files/(.*)", AuthenticatedFileHandler, {'path' : notebook_manager.notebook_dir}),
178 (r"/files/(.*)", AuthenticatedFileHandler, {'path' : notebook_manager.notebook_dir}),
172 (r"/clusters", MainClusterHandler),
179 (r"/clusters", MainClusterHandler),
173 (r"/clusters/%s/%s" % (_profile_regex, _cluster_action_regex), ClusterActionHandler),
180 (r"/clusters/%s/%s" % (_profile_regex, _cluster_action_regex), ClusterActionHandler),
174 (r"/clusters/%s" % _profile_regex, ClusterProfileHandler),
181 (r"/clusters/%s" % _profile_regex, ClusterProfileHandler),
175 ]
182 ]
176
183
177 # Python < 2.6.5 doesn't accept unicode keys in f(**kwargs), and
184 # Python < 2.6.5 doesn't accept unicode keys in f(**kwargs), and
178 # base_project_url will always be unicode, which will in turn
185 # base_project_url will always be unicode, which will in turn
179 # make the patterns unicode, and ultimately result in unicode
186 # make the patterns unicode, and ultimately result in unicode
180 # keys in kwargs to handler._execute(**kwargs) in tornado.
187 # keys in kwargs to handler._execute(**kwargs) in tornado.
181 # This enforces that base_project_url be ascii in that situation.
188 # This enforces that base_project_url be ascii in that situation.
182 #
189 #
183 # Note that the URLs these patterns check against are escaped,
190 # Note that the URLs these patterns check against are escaped,
184 # and thus guaranteed to be ASCII: 'héllo' is really 'h%C3%A9llo'.
191 # and thus guaranteed to be ASCII: 'héllo' is really 'h%C3%A9llo'.
185 base_project_url = py3compat.unicode_to_str(base_project_url, 'ascii')
192 base_project_url = py3compat.unicode_to_str(base_project_url, 'ascii')
186 template_path = os.path.join(os.path.dirname(__file__), "templates")
193 template_path = os.path.join(os.path.dirname(__file__), "templates")
187 settings = dict(
194 settings = dict(
188 # basics
195 # basics
189 base_project_url=base_project_url,
196 base_project_url=base_project_url,
190 base_kernel_url=ipython_app.base_kernel_url,
197 base_kernel_url=ipython_app.base_kernel_url,
191 template_path=template_path,
198 template_path=template_path,
192 static_path=ipython_app.static_file_path,
199 static_path=ipython_app.static_file_path,
193 static_handler_class = FileFindHandler,
200 static_handler_class = FileFindHandler,
194 static_url_prefix = url_path_join(base_project_url,'/static/'),
201 static_url_prefix = url_path_join(base_project_url,'/static/'),
195
202
196 # authentication
203 # authentication
197 cookie_secret=os.urandom(1024),
204 cookie_secret=os.urandom(1024),
198 login_url=url_path_join(base_project_url,'/login'),
205 login_url=url_path_join(base_project_url,'/login'),
199 cookie_name='username-%s' % uuid.uuid4(),
206 cookie_name='username-%s' % uuid.uuid4(),
200 read_only=ipython_app.read_only,
207 read_only=ipython_app.read_only,
201 password=ipython_app.password,
208 password=ipython_app.password,
202
209
203 # managers
210 # managers
204 kernel_manager=kernel_manager,
211 kernel_manager=kernel_manager,
205 notebook_manager=notebook_manager,
212 notebook_manager=notebook_manager,
206 cluster_manager=cluster_manager,
213 cluster_manager=cluster_manager,
207
214
208 # IPython stuff
215 # IPython stuff
209 mathjax_url=ipython_app.mathjax_url,
216 mathjax_url=ipython_app.mathjax_url,
210 max_msg_size=ipython_app.max_msg_size,
217 max_msg_size=ipython_app.max_msg_size,
211 config=ipython_app.config,
218 config=ipython_app.config,
212 use_less=ipython_app.use_less,
219 use_less=ipython_app.use_less,
213 jinja2_env=Environment(loader=FileSystemLoader(template_path)),
220 jinja2_env=Environment(loader=FileSystemLoader(template_path)),
214 )
221 )
215
222
216 # allow custom overrides for the tornado web app.
223 # allow custom overrides for the tornado web app.
217 settings.update(settings_overrides)
224 settings.update(settings_overrides)
218
225
219 # prepend base_project_url onto the patterns that we match
226 # prepend base_project_url onto the patterns that we match
220 new_handlers = []
227 new_handlers = []
221 for handler in handlers:
228 for handler in handlers:
222 pattern = url_path_join(base_project_url, handler[0])
229 pattern = url_path_join(base_project_url, handler[0])
223 new_handler = tuple([pattern] + list(handler[1:]))
230 new_handler = tuple([pattern] + list(handler[1:]))
224 new_handlers.append(new_handler)
231 new_handlers.append(new_handler)
225
232
226 super(NotebookWebApplication, self).__init__(new_handlers, **settings)
233 super(NotebookWebApplication, self).__init__(new_handlers, **settings)
227
234
228
235
229
236
230 #-----------------------------------------------------------------------------
237 #-----------------------------------------------------------------------------
231 # Aliases and Flags
238 # Aliases and Flags
232 #-----------------------------------------------------------------------------
239 #-----------------------------------------------------------------------------
233
240
234 flags = dict(kernel_flags)
241 flags = dict(kernel_flags)
235 flags['no-browser']=(
242 flags['no-browser']=(
236 {'NotebookApp' : {'open_browser' : False}},
243 {'NotebookApp' : {'open_browser' : False}},
237 "Don't open the notebook in a browser after startup."
244 "Don't open the notebook in a browser after startup."
238 )
245 )
239 flags['no-mathjax']=(
246 flags['no-mathjax']=(
240 {'NotebookApp' : {'enable_mathjax' : False}},
247 {'NotebookApp' : {'enable_mathjax' : False}},
241 """Disable MathJax
248 """Disable MathJax
242
249
243 MathJax is the javascript library IPython uses to render math/LaTeX. It is
250 MathJax is the javascript library IPython uses to render math/LaTeX. It is
244 very large, so you may want to disable it if you have a slow internet
251 very large, so you may want to disable it if you have a slow internet
245 connection, or for offline use of the notebook.
252 connection, or for offline use of the notebook.
246
253
247 When disabled, equations etc. will appear as their untransformed TeX source.
254 When disabled, equations etc. will appear as their untransformed TeX source.
248 """
255 """
249 )
256 )
250 flags['read-only'] = (
257 flags['read-only'] = (
251 {'NotebookApp' : {'read_only' : True}},
258 {'NotebookApp' : {'read_only' : True}},
252 """Allow read-only access to notebooks.
259 """Allow read-only access to notebooks.
253
260
254 When using a password to protect the notebook server, this flag
261 When using a password to protect the notebook server, this flag
255 allows unauthenticated clients to view the notebook list, and
262 allows unauthenticated clients to view the notebook list, and
256 individual notebooks, but not edit them, start kernels, or run
263 individual notebooks, but not edit them, start kernels, or run
257 code.
264 code.
258
265
259 If no password is set, the server will be entirely read-only.
266 If no password is set, the server will be entirely read-only.
260 """
267 """
261 )
268 )
262
269
263 # Add notebook manager flags
270 # Add notebook manager flags
264 flags.update(boolean_flag('script', 'FileNotebookManager.save_script',
271 flags.update(boolean_flag('script', 'FileNotebookManager.save_script',
265 'Auto-save a .py script everytime the .ipynb notebook is saved',
272 'Auto-save a .py script everytime the .ipynb notebook is saved',
266 'Do not auto-save .py scripts for every notebook'))
273 'Do not auto-save .py scripts for every notebook'))
267
274
268 # the flags that are specific to the frontend
275 # the flags that are specific to the frontend
269 # these must be scrubbed before being passed to the kernel,
276 # these must be scrubbed before being passed to the kernel,
270 # or it will raise an error on unrecognized flags
277 # or it will raise an error on unrecognized flags
271 notebook_flags = ['no-browser', 'no-mathjax', 'read-only', 'script', 'no-script']
278 notebook_flags = ['no-browser', 'no-mathjax', 'read-only', 'script', 'no-script']
272
279
273 aliases = dict(kernel_aliases)
280 aliases = dict(kernel_aliases)
274
281
275 aliases.update({
282 aliases.update({
276 'ip': 'NotebookApp.ip',
283 'ip': 'NotebookApp.ip',
277 'port': 'NotebookApp.port',
284 'port': 'NotebookApp.port',
278 'port-retries': 'NotebookApp.port_retries',
285 'port-retries': 'NotebookApp.port_retries',
279 'transport': 'KernelManager.transport',
286 'transport': 'KernelManager.transport',
280 'keyfile': 'NotebookApp.keyfile',
287 'keyfile': 'NotebookApp.keyfile',
281 'certfile': 'NotebookApp.certfile',
288 'certfile': 'NotebookApp.certfile',
282 'notebook-dir': 'NotebookManager.notebook_dir',
289 'notebook-dir': 'NotebookManager.notebook_dir',
283 'browser': 'NotebookApp.browser',
290 'browser': 'NotebookApp.browser',
284 })
291 })
285
292
286 # remove ipkernel flags that are singletons, and don't make sense in
293 # remove ipkernel flags that are singletons, and don't make sense in
287 # multi-kernel evironment:
294 # multi-kernel evironment:
288 aliases.pop('f', None)
295 aliases.pop('f', None)
289
296
290 notebook_aliases = [u'port', u'port-retries', u'ip', u'keyfile', u'certfile',
297 notebook_aliases = [u'port', u'port-retries', u'ip', u'keyfile', u'certfile',
291 u'notebook-dir']
298 u'notebook-dir']
292
299
293 #-----------------------------------------------------------------------------
300 #-----------------------------------------------------------------------------
294 # NotebookApp
301 # NotebookApp
295 #-----------------------------------------------------------------------------
302 #-----------------------------------------------------------------------------
296
303
297 class NotebookApp(BaseIPythonApplication):
304 class NotebookApp(BaseIPythonApplication):
298
305
299 name = 'ipython-notebook'
306 name = 'ipython-notebook'
300 default_config_file_name='ipython_notebook_config.py'
307 default_config_file_name='ipython_notebook_config.py'
301
308
302 description = """
309 description = """
303 The IPython HTML Notebook.
310 The IPython HTML Notebook.
304
311
305 This launches a Tornado based HTML Notebook Server that serves up an
312 This launches a Tornado based HTML Notebook Server that serves up an
306 HTML5/Javascript Notebook client.
313 HTML5/Javascript Notebook client.
307 """
314 """
308 examples = _examples
315 examples = _examples
309
316
310 classes = IPythonConsoleApp.classes + [MappingKernelManager, NotebookManager,
317 classes = IPythonConsoleApp.classes + [MappingKernelManager, NotebookManager,
311 FileNotebookManager]
318 FileNotebookManager]
312 flags = Dict(flags)
319 flags = Dict(flags)
313 aliases = Dict(aliases)
320 aliases = Dict(aliases)
314
321
315 kernel_argv = List(Unicode)
322 kernel_argv = List(Unicode)
316
323
317 max_msg_size = Integer(65536, config=True, help="""
324 max_msg_size = Integer(65536, config=True, help="""
318 The max raw message size accepted from the browser
325 The max raw message size accepted from the browser
319 over a WebSocket connection.
326 over a WebSocket connection.
320 """)
327 """)
321
328
322 def _log_level_default(self):
329 def _log_level_default(self):
323 return logging.INFO
330 return logging.INFO
324
331
325 def _log_format_default(self):
332 def _log_format_default(self):
326 """override default log format to include time"""
333 """override default log format to include time"""
327 return u"%(asctime)s.%(msecs).03d [%(name)s]%(highlevel)s %(message)s"
334 return u"%(asctime)s.%(msecs).03d [%(name)s]%(highlevel)s %(message)s"
328
335
329 # create requested profiles by default, if they don't exist:
336 # create requested profiles by default, if they don't exist:
330 auto_create = Bool(True)
337 auto_create = Bool(True)
331
338
332 # file to be opened in the notebook server
339 # file to be opened in the notebook server
333 file_to_run = Unicode('')
340 file_to_run = Unicode('')
334
341
335 # Network related information.
342 # Network related information.
336
343
337 ip = Unicode(LOCALHOST, config=True,
344 ip = Unicode(LOCALHOST, config=True,
338 help="The IP address the notebook server will listen on."
345 help="The IP address the notebook server will listen on."
339 )
346 )
340
347
341 def _ip_changed(self, name, old, new):
348 def _ip_changed(self, name, old, new):
342 if new == u'*': self.ip = u''
349 if new == u'*': self.ip = u''
343
350
344 port = Integer(8888, config=True,
351 port = Integer(8888, config=True,
345 help="The port the notebook server will listen on."
352 help="The port the notebook server will listen on."
346 )
353 )
347 port_retries = Integer(50, config=True,
354 port_retries = Integer(50, config=True,
348 help="The number of additional ports to try if the specified port is not available."
355 help="The number of additional ports to try if the specified port is not available."
349 )
356 )
350
357
351 certfile = Unicode(u'', config=True,
358 certfile = Unicode(u'', config=True,
352 help="""The full path to an SSL/TLS certificate file."""
359 help="""The full path to an SSL/TLS certificate file."""
353 )
360 )
354
361
355 keyfile = Unicode(u'', config=True,
362 keyfile = Unicode(u'', config=True,
356 help="""The full path to a private key file for usage with SSL/TLS."""
363 help="""The full path to a private key file for usage with SSL/TLS."""
357 )
364 )
358
365
359 password = Unicode(u'', config=True,
366 password = Unicode(u'', config=True,
360 help="""Hashed password to use for web authentication.
367 help="""Hashed password to use for web authentication.
361
368
362 To generate, type in a python/IPython shell:
369 To generate, type in a python/IPython shell:
363
370
364 from IPython.lib import passwd; passwd()
371 from IPython.lib import passwd; passwd()
365
372
366 The string should be of the form type:salt:hashed-password.
373 The string should be of the form type:salt:hashed-password.
367 """
374 """
368 )
375 )
369
376
370 open_browser = Bool(True, config=True,
377 open_browser = Bool(True, config=True,
371 help="""Whether to open in a browser after starting.
378 help="""Whether to open in a browser after starting.
372 The specific browser used is platform dependent and
379 The specific browser used is platform dependent and
373 determined by the python standard library `webbrowser`
380 determined by the python standard library `webbrowser`
374 module, unless it is overridden using the --browser
381 module, unless it is overridden using the --browser
375 (NotebookApp.browser) configuration option.
382 (NotebookApp.browser) configuration option.
376 """)
383 """)
377
384
378 browser = Unicode(u'', config=True,
385 browser = Unicode(u'', config=True,
379 help="""Specify what command to use to invoke a web
386 help="""Specify what command to use to invoke a web
380 browser when opening the notebook. If not specified, the
387 browser when opening the notebook. If not specified, the
381 default browser will be determined by the `webbrowser`
388 default browser will be determined by the `webbrowser`
382 standard library module, which allows setting of the
389 standard library module, which allows setting of the
383 BROWSER environment variable to override it.
390 BROWSER environment variable to override it.
384 """)
391 """)
385
392
386 read_only = Bool(False, config=True,
393 read_only = Bool(False, config=True,
387 help="Whether to prevent editing/execution of notebooks."
394 help="Whether to prevent editing/execution of notebooks."
388 )
395 )
389
396
390 use_less = Bool(False, config=True,
397 use_less = Bool(False, config=True,
391 help="""Wether to use Browser Side less-css parsing
398 help="""Wether to use Browser Side less-css parsing
392 instead of compiled css version in templates that allows
399 instead of compiled css version in templates that allows
393 it. This is mainly convenient when working on the less
400 it. This is mainly convenient when working on the less
394 file to avoid a build step, or if user want to overwrite
401 file to avoid a build step, or if user want to overwrite
395 some of the less variables without having to recompile
402 some of the less variables without having to recompile
396 everything.
403 everything.
397
404
398 You will need to install the less.js component in the static directory
405 You will need to install the less.js component in the static directory
399 either in the source tree or in your profile folder.
406 either in the source tree or in your profile folder.
400 """)
407 """)
401
408
402 webapp_settings = Dict(config=True,
409 webapp_settings = Dict(config=True,
403 help="Supply overrides for the tornado.web.Application that the "
410 help="Supply overrides for the tornado.web.Application that the "
404 "IPython notebook uses.")
411 "IPython notebook uses.")
405
412
406 enable_mathjax = Bool(True, config=True,
413 enable_mathjax = Bool(True, config=True,
407 help="""Whether to enable MathJax for typesetting math/TeX
414 help="""Whether to enable MathJax for typesetting math/TeX
408
415
409 MathJax is the javascript library IPython uses to render math/LaTeX. It is
416 MathJax is the javascript library IPython uses to render math/LaTeX. It is
410 very large, so you may want to disable it if you have a slow internet
417 very large, so you may want to disable it if you have a slow internet
411 connection, or for offline use of the notebook.
418 connection, or for offline use of the notebook.
412
419
413 When disabled, equations etc. will appear as their untransformed TeX source.
420 When disabled, equations etc. will appear as their untransformed TeX source.
414 """
421 """
415 )
422 )
416 def _enable_mathjax_changed(self, name, old, new):
423 def _enable_mathjax_changed(self, name, old, new):
417 """set mathjax url to empty if mathjax is disabled"""
424 """set mathjax url to empty if mathjax is disabled"""
418 if not new:
425 if not new:
419 self.mathjax_url = u''
426 self.mathjax_url = u''
420
427
421 base_project_url = Unicode('/', config=True,
428 base_project_url = Unicode('/', config=True,
422 help='''The base URL for the notebook server.
429 help='''The base URL for the notebook server.
423
430
424 Leading and trailing slashes can be omitted,
431 Leading and trailing slashes can be omitted,
425 and will automatically be added.
432 and will automatically be added.
426 ''')
433 ''')
427 def _base_project_url_changed(self, name, old, new):
434 def _base_project_url_changed(self, name, old, new):
428 if not new.startswith('/'):
435 if not new.startswith('/'):
429 self.base_project_url = '/'+new
436 self.base_project_url = '/'+new
430 elif not new.endswith('/'):
437 elif not new.endswith('/'):
431 self.base_project_url = new+'/'
438 self.base_project_url = new+'/'
432
439
433 base_kernel_url = Unicode('/', config=True,
440 base_kernel_url = Unicode('/', config=True,
434 help='''The base URL for the kernel server
441 help='''The base URL for the kernel server
435
442
436 Leading and trailing slashes can be omitted,
443 Leading and trailing slashes can be omitted,
437 and will automatically be added.
444 and will automatically be added.
438 ''')
445 ''')
439 def _base_kernel_url_changed(self, name, old, new):
446 def _base_kernel_url_changed(self, name, old, new):
440 if not new.startswith('/'):
447 if not new.startswith('/'):
441 self.base_kernel_url = '/'+new
448 self.base_kernel_url = '/'+new
442 elif not new.endswith('/'):
449 elif not new.endswith('/'):
443 self.base_kernel_url = new+'/'
450 self.base_kernel_url = new+'/'
444
451
445 websocket_host = Unicode("", config=True,
452 websocket_host = Unicode("", config=True,
446 help="""The hostname for the websocket server."""
453 help="""The hostname for the websocket server."""
447 )
454 )
448
455
449 extra_static_paths = List(Unicode, config=True,
456 extra_static_paths = List(Unicode, config=True,
450 help="""Extra paths to search for serving static files.
457 help="""Extra paths to search for serving static files.
451
458
452 This allows adding javascript/css to be available from the notebook server machine,
459 This allows adding javascript/css to be available from the notebook server machine,
453 or overriding individual files in the IPython"""
460 or overriding individual files in the IPython"""
454 )
461 )
455 def _extra_static_paths_default(self):
462 def _extra_static_paths_default(self):
456 return [os.path.join(self.profile_dir.location, 'static')]
463 return [os.path.join(self.profile_dir.location, 'static')]
457
464
458 @property
465 @property
459 def static_file_path(self):
466 def static_file_path(self):
460 """return extra paths + the default location"""
467 """return extra paths + the default location"""
461 return self.extra_static_paths + [DEFAULT_STATIC_FILES_PATH]
468 return self.extra_static_paths + [DEFAULT_STATIC_FILES_PATH]
462
469
463 mathjax_url = Unicode("", config=True,
470 mathjax_url = Unicode("", config=True,
464 help="""The url for MathJax.js."""
471 help="""The url for MathJax.js."""
465 )
472 )
466 def _mathjax_url_default(self):
473 def _mathjax_url_default(self):
467 if not self.enable_mathjax:
474 if not self.enable_mathjax:
468 return u''
475 return u''
469 static_url_prefix = self.webapp_settings.get("static_url_prefix",
476 static_url_prefix = self.webapp_settings.get("static_url_prefix",
470 "/static/")
477 "/static/")
471 try:
478 try:
472 mathjax = filefind(os.path.join('mathjax', 'MathJax.js'), self.static_file_path)
479 mathjax = filefind(os.path.join('mathjax', 'MathJax.js'), self.static_file_path)
473 except IOError:
480 except IOError:
474 if self.certfile:
481 if self.certfile:
475 # HTTPS: load from Rackspace CDN, because SSL certificate requires it
482 # HTTPS: load from Rackspace CDN, because SSL certificate requires it
476 base = u"https://c328740.ssl.cf1.rackcdn.com"
483 base = u"https://c328740.ssl.cf1.rackcdn.com"
477 else:
484 else:
478 base = u"http://cdn.mathjax.org"
485 base = u"http://cdn.mathjax.org"
479
486
480 url = base + u"/mathjax/latest/MathJax.js"
487 url = base + u"/mathjax/latest/MathJax.js"
481 self.log.info("Using MathJax from CDN: %s", url)
488 self.log.info("Using MathJax from CDN: %s", url)
482 return url
489 return url
483 else:
490 else:
484 self.log.info("Using local MathJax from %s" % mathjax)
491 self.log.info("Using local MathJax from %s" % mathjax)
485 return static_url_prefix+u"mathjax/MathJax.js"
492 return static_url_prefix+u"mathjax/MathJax.js"
486
493
487 def _mathjax_url_changed(self, name, old, new):
494 def _mathjax_url_changed(self, name, old, new):
488 if new and not self.enable_mathjax:
495 if new and not self.enable_mathjax:
489 # enable_mathjax=False overrides mathjax_url
496 # enable_mathjax=False overrides mathjax_url
490 self.mathjax_url = u''
497 self.mathjax_url = u''
491 else:
498 else:
492 self.log.info("Using MathJax: %s", new)
499 self.log.info("Using MathJax: %s", new)
493
500
494 notebook_manager_class = DottedObjectName('IPython.frontend.html.notebook.filenbmanager.FileNotebookManager',
501 notebook_manager_class = DottedObjectName('IPython.frontend.html.notebook.filenbmanager.FileNotebookManager',
495 config=True,
502 config=True,
496 help='The notebook manager class to use.')
503 help='The notebook manager class to use.')
497
504
498 trust_xheaders = Bool(False, config=True,
505 trust_xheaders = Bool(False, config=True,
499 help=("Whether to trust or not X-Scheme/X-Forwarded-Proto and X-Real-Ip/X-Forwarded-For headers"
506 help=("Whether to trust or not X-Scheme/X-Forwarded-Proto and X-Real-Ip/X-Forwarded-For headers"
500 "sent by the upstream reverse proxy. Neccesary if the proxy handles SSL")
507 "sent by the upstream reverse proxy. Neccesary if the proxy handles SSL")
501 )
508 )
502
509
503 def parse_command_line(self, argv=None):
510 def parse_command_line(self, argv=None):
504 super(NotebookApp, self).parse_command_line(argv)
511 super(NotebookApp, self).parse_command_line(argv)
505 if argv is None:
512 if argv is None:
506 argv = sys.argv[1:]
513 argv = sys.argv[1:]
507
514
508 # Scrub frontend-specific flags
515 # Scrub frontend-specific flags
509 self.kernel_argv = swallow_argv(argv, notebook_aliases, notebook_flags)
516 self.kernel_argv = swallow_argv(argv, notebook_aliases, notebook_flags)
510 # Kernel should inherit default config file from frontend
517 # Kernel should inherit default config file from frontend
511 self.kernel_argv.append("--IPKernelApp.parent_appname='%s'" % self.name)
518 self.kernel_argv.append("--IPKernelApp.parent_appname='%s'" % self.name)
512
519
513 if self.extra_args:
520 if self.extra_args:
514 f = os.path.abspath(self.extra_args[0])
521 f = os.path.abspath(self.extra_args[0])
515 if os.path.isdir(f):
522 if os.path.isdir(f):
516 nbdir = f
523 nbdir = f
517 else:
524 else:
518 self.file_to_run = f
525 self.file_to_run = f
519 nbdir = os.path.dirname(f)
526 nbdir = os.path.dirname(f)
520 self.config.NotebookManager.notebook_dir = nbdir
527 self.config.NotebookManager.notebook_dir = nbdir
521
528
522 def init_configurables(self):
529 def init_configurables(self):
523 # force Session default to be secure
530 # force Session default to be secure
524 default_secure(self.config)
531 default_secure(self.config)
525 self.kernel_manager = MappingKernelManager(
532 self.kernel_manager = MappingKernelManager(
526 config=self.config, log=self.log, kernel_argv=self.kernel_argv,
533 config=self.config, log=self.log, kernel_argv=self.kernel_argv,
527 connection_dir = self.profile_dir.security_dir,
534 connection_dir = self.profile_dir.security_dir,
528 )
535 )
529 kls = import_item(self.notebook_manager_class)
536 kls = import_item(self.notebook_manager_class)
530 self.notebook_manager = kls(config=self.config, log=self.log)
537 self.notebook_manager = kls(config=self.config, log=self.log)
531 self.notebook_manager.load_notebook_names()
538 self.notebook_manager.load_notebook_names()
532 self.cluster_manager = ClusterManager(config=self.config, log=self.log)
539 self.cluster_manager = ClusterManager(config=self.config, log=self.log)
533 self.cluster_manager.update_profiles()
540 self.cluster_manager.update_profiles()
534
541
535 def init_logging(self):
542 def init_logging(self):
536 # This prevents double log messages because tornado use a root logger that
543 # This prevents double log messages because tornado use a root logger that
537 # self.log is a child of. The logging module dipatches log messages to a log
544 # self.log is a child of. The logging module dipatches log messages to a log
538 # and all of its ancenstors until propagate is set to False.
545 # and all of its ancenstors until propagate is set to False.
539 self.log.propagate = False
546 self.log.propagate = False
540
547
541 # hook up tornado 3's loggers to our app handlers
548 # hook up tornado 3's loggers to our app handlers
542 for name in ('access', 'application', 'general'):
549 for name in ('access', 'application', 'general'):
543 logging.getLogger('tornado.%s' % name).handlers = self.log.handlers
550 logging.getLogger('tornado.%s' % name).handlers = self.log.handlers
544
551
545 def init_webapp(self):
552 def init_webapp(self):
546 """initialize tornado webapp and httpserver"""
553 """initialize tornado webapp and httpserver"""
547 self.web_app = NotebookWebApplication(
554 self.web_app = NotebookWebApplication(
548 self, self.kernel_manager, self.notebook_manager,
555 self, self.kernel_manager, self.notebook_manager,
549 self.cluster_manager, self.log,
556 self.cluster_manager, self.log,
550 self.base_project_url, self.webapp_settings
557 self.base_project_url, self.webapp_settings
551 )
558 )
552 if self.certfile:
559 if self.certfile:
553 ssl_options = dict(certfile=self.certfile)
560 ssl_options = dict(certfile=self.certfile)
554 if self.keyfile:
561 if self.keyfile:
555 ssl_options['keyfile'] = self.keyfile
562 ssl_options['keyfile'] = self.keyfile
556 else:
563 else:
557 ssl_options = None
564 ssl_options = None
558 self.web_app.password = self.password
565 self.web_app.password = self.password
559 self.http_server = httpserver.HTTPServer(self.web_app, ssl_options=ssl_options,
566 self.http_server = httpserver.HTTPServer(self.web_app, ssl_options=ssl_options,
560 xheaders=self.trust_xheaders)
567 xheaders=self.trust_xheaders)
561 if not self.ip:
568 if not self.ip:
562 warning = "WARNING: The notebook server is listening on all IP addresses"
569 warning = "WARNING: The notebook server is listening on all IP addresses"
563 if ssl_options is None:
570 if ssl_options is None:
564 self.log.critical(warning + " and not using encryption. This"
571 self.log.critical(warning + " and not using encryption. This"
565 "is not recommended.")
572 "is not recommended.")
566 if not self.password and not self.read_only:
573 if not self.password and not self.read_only:
567 self.log.critical(warning + "and not using authentication."
574 self.log.critical(warning + "and not using authentication."
568 "This is highly insecure and not recommended.")
575 "This is highly insecure and not recommended.")
569 success = None
576 success = None
570 for port in random_ports(self.port, self.port_retries+1):
577 for port in random_ports(self.port, self.port_retries+1):
571 try:
578 try:
572 self.http_server.listen(port, self.ip)
579 self.http_server.listen(port, self.ip)
573 except socket.error as e:
580 except socket.error as e:
574 # XXX: remove the e.errno == -9 block when we require
581 # XXX: remove the e.errno == -9 block when we require
575 # tornado >= 3.0
582 # tornado >= 3.0
576 if e.errno == -9 and tornado.version_info[0] < 3:
583 if e.errno == -9 and tornado.version_info[0] < 3:
577 # The flags passed to socket.getaddrinfo from
584 # The flags passed to socket.getaddrinfo from
578 # tornado.netutils.bind_sockets can cause "gaierror:
585 # tornado.netutils.bind_sockets can cause "gaierror:
579 # [Errno -9] Address family for hostname not supported"
586 # [Errno -9] Address family for hostname not supported"
580 # when the interface is not associated, for example.
587 # when the interface is not associated, for example.
581 # Changing the flags to exclude socket.AI_ADDRCONFIG does
588 # Changing the flags to exclude socket.AI_ADDRCONFIG does
582 # not cause this error, but the only way to do this is to
589 # not cause this error, but the only way to do this is to
583 # monkeypatch socket to remove the AI_ADDRCONFIG attribute
590 # monkeypatch socket to remove the AI_ADDRCONFIG attribute
584 saved_AI_ADDRCONFIG = socket.AI_ADDRCONFIG
591 saved_AI_ADDRCONFIG = socket.AI_ADDRCONFIG
585 self.log.warn('Monkeypatching socket to fix tornado bug')
592 self.log.warn('Monkeypatching socket to fix tornado bug')
586 del(socket.AI_ADDRCONFIG)
593 del(socket.AI_ADDRCONFIG)
587 try:
594 try:
588 # retry the tornado call without AI_ADDRCONFIG flags
595 # retry the tornado call without AI_ADDRCONFIG flags
589 self.http_server.listen(port, self.ip)
596 self.http_server.listen(port, self.ip)
590 except socket.error as e2:
597 except socket.error as e2:
591 e = e2
598 e = e2
592 else:
599 else:
593 self.port = port
600 self.port = port
594 success = True
601 success = True
595 break
602 break
596 # restore the monekypatch
603 # restore the monekypatch
597 socket.AI_ADDRCONFIG = saved_AI_ADDRCONFIG
604 socket.AI_ADDRCONFIG = saved_AI_ADDRCONFIG
598 if e.errno != errno.EADDRINUSE:
605 if e.errno != errno.EADDRINUSE:
599 raise
606 raise
600 self.log.info('The port %i is already in use, trying another random port.' % port)
607 self.log.info('The port %i is already in use, trying another random port.' % port)
601 else:
608 else:
602 self.port = port
609 self.port = port
603 success = True
610 success = True
604 break
611 break
605 if not success:
612 if not success:
606 self.log.critical('ERROR: the notebook server could not be started because '
613 self.log.critical('ERROR: the notebook server could not be started because '
607 'no available port could be found.')
614 'no available port could be found.')
608 self.exit(1)
615 self.exit(1)
609
616
610 def init_signal(self):
617 def init_signal(self):
611 if not sys.platform.startswith('win'):
618 if not sys.platform.startswith('win'):
612 signal.signal(signal.SIGINT, self._handle_sigint)
619 signal.signal(signal.SIGINT, self._handle_sigint)
613 signal.signal(signal.SIGTERM, self._signal_stop)
620 signal.signal(signal.SIGTERM, self._signal_stop)
614 if hasattr(signal, 'SIGUSR1'):
621 if hasattr(signal, 'SIGUSR1'):
615 # Windows doesn't support SIGUSR1
622 # Windows doesn't support SIGUSR1
616 signal.signal(signal.SIGUSR1, self._signal_info)
623 signal.signal(signal.SIGUSR1, self._signal_info)
617 if hasattr(signal, 'SIGINFO'):
624 if hasattr(signal, 'SIGINFO'):
618 # only on BSD-based systems
625 # only on BSD-based systems
619 signal.signal(signal.SIGINFO, self._signal_info)
626 signal.signal(signal.SIGINFO, self._signal_info)
620
627
621 def _handle_sigint(self, sig, frame):
628 def _handle_sigint(self, sig, frame):
622 """SIGINT handler spawns confirmation dialog"""
629 """SIGINT handler spawns confirmation dialog"""
623 # register more forceful signal handler for ^C^C case
630 # register more forceful signal handler for ^C^C case
624 signal.signal(signal.SIGINT, self._signal_stop)
631 signal.signal(signal.SIGINT, self._signal_stop)
625 # request confirmation dialog in bg thread, to avoid
632 # request confirmation dialog in bg thread, to avoid
626 # blocking the App
633 # blocking the App
627 thread = threading.Thread(target=self._confirm_exit)
634 thread = threading.Thread(target=self._confirm_exit)
628 thread.daemon = True
635 thread.daemon = True
629 thread.start()
636 thread.start()
630
637
631 def _restore_sigint_handler(self):
638 def _restore_sigint_handler(self):
632 """callback for restoring original SIGINT handler"""
639 """callback for restoring original SIGINT handler"""
633 signal.signal(signal.SIGINT, self._handle_sigint)
640 signal.signal(signal.SIGINT, self._handle_sigint)
634
641
635 def _confirm_exit(self):
642 def _confirm_exit(self):
636 """confirm shutdown on ^C
643 """confirm shutdown on ^C
637
644
638 A second ^C, or answering 'y' within 5s will cause shutdown,
645 A second ^C, or answering 'y' within 5s will cause shutdown,
639 otherwise original SIGINT handler will be restored.
646 otherwise original SIGINT handler will be restored.
640
647
641 This doesn't work on Windows.
648 This doesn't work on Windows.
642 """
649 """
643 # FIXME: remove this delay when pyzmq dependency is >= 2.1.11
650 # FIXME: remove this delay when pyzmq dependency is >= 2.1.11
644 time.sleep(0.1)
651 time.sleep(0.1)
645 info = self.log.info
652 info = self.log.info
646 info('interrupted')
653 info('interrupted')
647 print self.notebook_info()
654 print self.notebook_info()
648 sys.stdout.write("Shutdown this notebook server (y/[n])? ")
655 sys.stdout.write("Shutdown this notebook server (y/[n])? ")
649 sys.stdout.flush()
656 sys.stdout.flush()
650 r,w,x = select.select([sys.stdin], [], [], 5)
657 r,w,x = select.select([sys.stdin], [], [], 5)
651 if r:
658 if r:
652 line = sys.stdin.readline()
659 line = sys.stdin.readline()
653 if line.lower().startswith('y'):
660 if line.lower().startswith('y'):
654 self.log.critical("Shutdown confirmed")
661 self.log.critical("Shutdown confirmed")
655 ioloop.IOLoop.instance().stop()
662 ioloop.IOLoop.instance().stop()
656 return
663 return
657 else:
664 else:
658 print "No answer for 5s:",
665 print "No answer for 5s:",
659 print "resuming operation..."
666 print "resuming operation..."
660 # no answer, or answer is no:
667 # no answer, or answer is no:
661 # set it back to original SIGINT handler
668 # set it back to original SIGINT handler
662 # use IOLoop.add_callback because signal.signal must be called
669 # use IOLoop.add_callback because signal.signal must be called
663 # from main thread
670 # from main thread
664 ioloop.IOLoop.instance().add_callback(self._restore_sigint_handler)
671 ioloop.IOLoop.instance().add_callback(self._restore_sigint_handler)
665
672
666 def _signal_stop(self, sig, frame):
673 def _signal_stop(self, sig, frame):
667 self.log.critical("received signal %s, stopping", sig)
674 self.log.critical("received signal %s, stopping", sig)
668 ioloop.IOLoop.instance().stop()
675 ioloop.IOLoop.instance().stop()
669
676
670 def _signal_info(self, sig, frame):
677 def _signal_info(self, sig, frame):
671 print self.notebook_info()
678 print self.notebook_info()
672
679
673 def init_components(self):
680 def init_components(self):
674 """Check the components submodule, and warn if it's unclean"""
681 """Check the components submodule, and warn if it's unclean"""
675 status = submodule.check_submodule_status()
682 status = submodule.check_submodule_status()
676 if status == 'missing':
683 if status == 'missing':
677 self.log.warn("components submodule missing, running `git submodule update`")
684 self.log.warn("components submodule missing, running `git submodule update`")
678 submodule.update_submodules(submodule.ipython_parent())
685 submodule.update_submodules(submodule.ipython_parent())
679 elif status == 'unclean':
686 elif status == 'unclean':
680 self.log.warn("components submodule unclean, you may see 404s on static/components")
687 self.log.warn("components submodule unclean, you may see 404s on static/components")
681 self.log.warn("run `setup.py submodule` or `git submodule update` to update")
688 self.log.warn("run `setup.py submodule` or `git submodule update` to update")
682
689
683
690
684 @catch_config_error
691 @catch_config_error
685 def initialize(self, argv=None):
692 def initialize(self, argv=None):
686 self.init_logging()
693 self.init_logging()
687 super(NotebookApp, self).initialize(argv)
694 super(NotebookApp, self).initialize(argv)
688 self.init_configurables()
695 self.init_configurables()
689 self.init_components()
696 self.init_components()
690 self.init_webapp()
697 self.init_webapp()
691 self.init_signal()
698 self.init_signal()
692
699
693 def cleanup_kernels(self):
700 def cleanup_kernels(self):
694 """Shutdown all kernels.
701 """Shutdown all kernels.
695
702
696 The kernels will shutdown themselves when this process no longer exists,
703 The kernels will shutdown themselves when this process no longer exists,
697 but explicit shutdown allows the KernelManagers to cleanup the connection files.
704 but explicit shutdown allows the KernelManagers to cleanup the connection files.
698 """
705 """
699 self.log.info('Shutting down kernels')
706 self.log.info('Shutting down kernels')
700 self.kernel_manager.shutdown_all()
707 self.kernel_manager.shutdown_all()
701
708
702 def notebook_info(self):
709 def notebook_info(self):
703 "Return the current working directory and the server url information"
710 "Return the current working directory and the server url information"
704 mgr_info = self.notebook_manager.info_string() + "\n"
711 mgr_info = self.notebook_manager.info_string() + "\n"
705 return mgr_info +"The IPython Notebook is running at: %s" % self._url
712 return mgr_info +"The IPython Notebook is running at: %s" % self._url
706
713
707 def start(self):
714 def start(self):
708 """ Start the IPython Notebook server app, after initialization
715 """ Start the IPython Notebook server app, after initialization
709
716
710 This method takes no arguments so all configuration and initialization
717 This method takes no arguments so all configuration and initialization
711 must be done prior to calling this method."""
718 must be done prior to calling this method."""
712 ip = self.ip if self.ip else '[all ip addresses on your system]'
719 ip = self.ip if self.ip else '[all ip addresses on your system]'
713 proto = 'https' if self.certfile else 'http'
720 proto = 'https' if self.certfile else 'http'
714 info = self.log.info
721 info = self.log.info
715 self._url = "%s://%s:%i%s" % (proto, ip, self.port,
722 self._url = "%s://%s:%i%s" % (proto, ip, self.port,
716 self.base_project_url)
723 self.base_project_url)
717 for line in self.notebook_info().split("\n"):
724 for line in self.notebook_info().split("\n"):
718 info(line)
725 info(line)
719 info("Use Control-C to stop this server and shut down all kernels.")
726 info("Use Control-C to stop this server and shut down all kernels.")
720
727
721 if self.open_browser or self.file_to_run:
728 if self.open_browser or self.file_to_run:
722 ip = self.ip or LOCALHOST
729 ip = self.ip or LOCALHOST
723 try:
730 try:
724 browser = webbrowser.get(self.browser or None)
731 browser = webbrowser.get(self.browser or None)
725 except webbrowser.Error as e:
732 except webbrowser.Error as e:
726 self.log.warn('No web browser found: %s.' % e)
733 self.log.warn('No web browser found: %s.' % e)
727 browser = None
734 browser = None
728
735
729 if self.file_to_run:
736 if self.file_to_run:
730 name, _ = os.path.splitext(os.path.basename(self.file_to_run))
737 name, _ = os.path.splitext(os.path.basename(self.file_to_run))
731 url = self.notebook_manager.rev_mapping.get(name, '')
738 url = self.notebook_manager.rev_mapping.get(name, '')
732 else:
739 else:
733 url = ''
740 url = ''
734 if browser:
741 if browser:
735 b = lambda : browser.open("%s://%s:%i%s%s" % (proto, ip,
742 b = lambda : browser.open("%s://%s:%i%s%s" % (proto, ip,
736 self.port, self.base_project_url, url), new=2)
743 self.port, self.base_project_url, url), new=2)
737 threading.Thread(target=b).start()
744 threading.Thread(target=b).start()
738 try:
745 try:
739 ioloop.IOLoop.instance().start()
746 ioloop.IOLoop.instance().start()
740 except KeyboardInterrupt:
747 except KeyboardInterrupt:
741 info("Interrupted...")
748 info("Interrupted...")
742 finally:
749 finally:
743 self.cleanup_kernels()
750 self.cleanup_kernels()
744
751
745
752
746 #-----------------------------------------------------------------------------
753 #-----------------------------------------------------------------------------
747 # Main entry point
754 # Main entry point
748 #-----------------------------------------------------------------------------
755 #-----------------------------------------------------------------------------
749
756
750 def launch_new_instance():
757 def launch_new_instance():
751 app = NotebookApp.instance()
758 app = NotebookApp.instance()
752 app.initialize()
759 app.initialize()
753 app.start()
760 app.start()
754
761
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (931 lines changed) Show them Hide them
General Comments 0
You need to be logged in to leave comments. Login now