##// END OF EJS Templates
Starting to refactor heart beating of notebook kernels.
Brian E. Granger -
Show More
@@ -1,919 +1,875 b''
1 1 """Tornado handlers for the notebook.
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2008-2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19 import Cookie
20 20 import datetime
21 21 import email.utils
22 22 import hashlib
23 23 import logging
24 24 import mimetypes
25 25 import os
26 26 import stat
27 27 import threading
28 28 import time
29 29 import uuid
30 30
31 31 from tornado.escape import url_escape
32 32 from tornado import web
33 33 from tornado import websocket
34 34
35 35 from zmq.eventloop import ioloop
36 36 from zmq.utils import jsonapi
37 37
38 38 from IPython.external.decorator import decorator
39 39 from IPython.kernel.zmq.session import Session
40 40 from IPython.lib.security import passwd_check
41 41 from IPython.utils.jsonutil import date_default
42 42 from IPython.utils.path import filefind
43 43 from IPython.utils.py3compat import PY3
44 44
45 45 try:
46 46 from docutils.core import publish_string
47 47 except ImportError:
48 48 publish_string = None
49 49
50 50 #-----------------------------------------------------------------------------
51 51 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
52 52 #-----------------------------------------------------------------------------
53 53
54 54 # Google Chrome, as of release 16, changed its websocket protocol number. The
55 55 # parts tornado cares about haven't really changed, so it's OK to continue
56 56 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
57 57 # version as of Oct 30/2011) the version check fails, see the issue report:
58 58
59 59 # https://github.com/facebook/tornado/issues/385
60 60
61 61 # This issue has been fixed in Tornado post 2.1.1:
62 62
63 63 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
64 64
65 65 # Here we manually apply the same patch as above so that users of IPython can
66 66 # continue to work with an officially released Tornado. We make the
67 67 # monkeypatch version check as narrow as possible to limit its effects; once
68 68 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
69 69
70 70 import tornado
71 71
72 72 if tornado.version_info <= (2,1,1):
73 73
74 74 def _execute(self, transforms, *args, **kwargs):
75 75 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
76 76
77 77 self.open_args = args
78 78 self.open_kwargs = kwargs
79 79
80 80 # The difference between version 8 and 13 is that in 8 the
81 81 # client sends a "Sec-Websocket-Origin" header and in 13 it's
82 82 # simply "Origin".
83 83 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
84 84 self.ws_connection = WebSocketProtocol8(self)
85 85 self.ws_connection.accept_connection()
86 86
87 87 elif self.request.headers.get("Sec-WebSocket-Version"):
88 88 self.stream.write(tornado.escape.utf8(
89 89 "HTTP/1.1 426 Upgrade Required\r\n"
90 90 "Sec-WebSocket-Version: 8\r\n\r\n"))
91 91 self.stream.close()
92 92
93 93 else:
94 94 self.ws_connection = WebSocketProtocol76(self)
95 95 self.ws_connection.accept_connection()
96 96
97 97 websocket.WebSocketHandler._execute = _execute
98 98 del _execute
99 99
100 100 #-----------------------------------------------------------------------------
101 101 # Decorator for disabling read-only handlers
102 102 #-----------------------------------------------------------------------------
103 103
104 104 @decorator
105 105 def not_if_readonly(f, self, *args, **kwargs):
106 106 if self.application.read_only:
107 107 raise web.HTTPError(403, "Notebook server is read-only")
108 108 else:
109 109 return f(self, *args, **kwargs)
110 110
111 111 @decorator
112 112 def authenticate_unless_readonly(f, self, *args, **kwargs):
113 113 """authenticate this page *unless* readonly view is active.
114 114
115 115 In read-only mode, the notebook list and print view should
116 116 be accessible without authentication.
117 117 """
118 118
119 119 @web.authenticated
120 120 def auth_f(self, *args, **kwargs):
121 121 return f(self, *args, **kwargs)
122 122
123 123 if self.application.read_only:
124 124 return f(self, *args, **kwargs)
125 125 else:
126 126 return auth_f(self, *args, **kwargs)
127 127
128 128 def urljoin(*pieces):
129 129 """Join componenet of url into a relative url
130 130
131 131 Use to prevent double slash when joining subpath
132 132 """
133 133 striped = [s.strip('/') for s in pieces]
134 134 return '/'.join(s for s in striped if s)
135 135
136 136 #-----------------------------------------------------------------------------
137 137 # Top-level handlers
138 138 #-----------------------------------------------------------------------------
139 139
140 140 class RequestHandler(web.RequestHandler):
141 141 """RequestHandler with default variable setting."""
142 142
143 143 def render(*args, **kwargs):
144 144 kwargs.setdefault('message', '')
145 145 return web.RequestHandler.render(*args, **kwargs)
146 146
147 147 class AuthenticatedHandler(RequestHandler):
148 148 """A RequestHandler with an authenticated user."""
149 149
150 150 def get_current_user(self):
151 151 user_id = self.get_secure_cookie(self.settings['cookie_name'])
152 152 # For now the user_id should not return empty, but it could eventually
153 153 if user_id == '':
154 154 user_id = 'anonymous'
155 155 if user_id is None:
156 156 # prevent extra Invalid cookie sig warnings:
157 157 self.clear_cookie(self.settings['cookie_name'])
158 158 if not self.application.password and not self.application.read_only:
159 159 user_id = 'anonymous'
160 160 return user_id
161 161
162 162 @property
163 163 def logged_in(self):
164 164 """Is a user currently logged in?
165 165
166 166 """
167 167 user = self.get_current_user()
168 168 return (user and not user == 'anonymous')
169 169
170 170 @property
171 171 def login_available(self):
172 172 """May a user proceed to log in?
173 173
174 174 This returns True if login capability is available, irrespective of
175 175 whether the user is already logged in or not.
176 176
177 177 """
178 178 return bool(self.application.password)
179 179
180 180 @property
181 181 def read_only(self):
182 182 """Is the notebook read-only?
183 183
184 184 """
185 185 return self.application.read_only
186 186
187 187 @property
188 188 def use_less(self):
189 189 """Use less instead of css in templates"""
190 190 return self.application.use_less
191 191
192 192 @property
193 193 def ws_url(self):
194 194 """websocket url matching the current request
195 195
196 196 turns http[s]://host[:port] into
197 197 ws[s]://host[:port]
198 198 """
199 199 proto = self.request.protocol.replace('http', 'ws')
200 200 host = self.application.ipython_app.websocket_host # default to config value
201 201 if host == '':
202 202 host = self.request.host # get from request
203 203 return "%s://%s" % (proto, host)
204 204
205 205
206 206 class AuthenticatedFileHandler(AuthenticatedHandler, web.StaticFileHandler):
207 207 """static files should only be accessible when logged in"""
208 208
209 209 @authenticate_unless_readonly
210 210 def get(self, path):
211 211 return web.StaticFileHandler.get(self, path)
212 212
213 213
214 214 class ProjectDashboardHandler(AuthenticatedHandler):
215 215
216 216 @authenticate_unless_readonly
217 217 def get(self):
218 218 nbm = self.application.notebook_manager
219 219 project = nbm.notebook_dir
220 220 template = self.application.jinja2_env.get_template('projectdashboard.html')
221 221 self.write( template.render(
222 222 project=project,
223 223 project_component=project.split('/'),
224 224 base_project_url=self.application.ipython_app.base_project_url,
225 225 base_kernel_url=self.application.ipython_app.base_kernel_url,
226 226 read_only=self.read_only,
227 227 logged_in=self.logged_in,
228 228 use_less=self.use_less,
229 229 login_available=self.login_available))
230 230
231 231
232 232 class LoginHandler(AuthenticatedHandler):
233 233
234 234 def _render(self, message=None):
235 235 template = self.application.jinja2_env.get_template('login.html')
236 236 self.write( template.render(
237 237 next=url_escape(self.get_argument('next', default=self.application.ipython_app.base_project_url)),
238 238 read_only=self.read_only,
239 239 logged_in=self.logged_in,
240 240 login_available=self.login_available,
241 241 base_project_url=self.application.ipython_app.base_project_url,
242 242 message=message
243 243 ))
244 244
245 245 def get(self):
246 246 if self.current_user:
247 247 self.redirect(self.get_argument('next', default=self.application.ipython_app.base_project_url))
248 248 else:
249 249 self._render()
250 250
251 251 def post(self):
252 252 pwd = self.get_argument('password', default=u'')
253 253 if self.application.password:
254 254 if passwd_check(self.application.password, pwd):
255 255 self.set_secure_cookie(self.settings['cookie_name'], str(uuid.uuid4()))
256 256 else:
257 257 self._render(message={'error': 'Invalid password'})
258 258 return
259 259
260 260 self.redirect(self.get_argument('next', default=self.application.ipython_app.base_project_url))
261 261
262 262
263 263 class LogoutHandler(AuthenticatedHandler):
264 264
265 265 def get(self):
266 266 self.clear_cookie(self.settings['cookie_name'])
267 267 if self.login_available:
268 268 message = {'info': 'Successfully logged out.'}
269 269 else:
270 270 message = {'warning': 'Cannot log out. Notebook authentication '
271 271 'is disabled.'}
272 272 template = self.application.jinja2_env.get_template('logout.html')
273 273 self.write( template.render(
274 274 read_only=self.read_only,
275 275 logged_in=self.logged_in,
276 276 login_available=self.login_available,
277 277 base_project_url=self.application.ipython_app.base_project_url,
278 278 message=message))
279 279
280 280
281 281 class NewHandler(AuthenticatedHandler):
282 282
283 283 @web.authenticated
284 284 def get(self):
285 285 nbm = self.application.notebook_manager
286 286 project = nbm.notebook_dir
287 287 notebook_id = nbm.new_notebook()
288 288 self.redirect('/'+urljoin(self.application.ipython_app.base_project_url, notebook_id))
289 289
290 290 class NamedNotebookHandler(AuthenticatedHandler):
291 291
292 292 @authenticate_unless_readonly
293 293 def get(self, notebook_id):
294 294 nbm = self.application.notebook_manager
295 295 project = nbm.notebook_dir
296 296 if not nbm.notebook_exists(notebook_id):
297 297 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
298 298 template = self.application.jinja2_env.get_template('notebook.html')
299 299 self.write( template.render(
300 300 project=project,
301 301 notebook_id=notebook_id,
302 302 base_project_url=self.application.ipython_app.base_project_url,
303 303 base_kernel_url=self.application.ipython_app.base_kernel_url,
304 304 kill_kernel=False,
305 305 read_only=self.read_only,
306 306 logged_in=self.logged_in,
307 307 login_available=self.login_available,
308 308 mathjax_url=self.application.ipython_app.mathjax_url,
309 309 use_less=self.use_less
310 310 )
311 311 )
312 312
313 313
314 314 class PrintNotebookHandler(AuthenticatedHandler):
315 315
316 316 @authenticate_unless_readonly
317 317 def get(self, notebook_id):
318 318 nbm = self.application.notebook_manager
319 319 project = nbm.notebook_dir
320 320 if not nbm.notebook_exists(notebook_id):
321 321 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_id)
322 322 template = self.application.jinja2_env.get_template('printnotebook.html')
323 323 self.write( template.render(
324 324 project=project,
325 325 notebook_id=notebook_id,
326 326 base_project_url=self.application.ipython_app.base_project_url,
327 327 base_kernel_url=self.application.ipython_app.base_kernel_url,
328 328 kill_kernel=False,
329 329 read_only=self.read_only,
330 330 logged_in=self.logged_in,
331 331 login_available=self.login_available,
332 332 mathjax_url=self.application.ipython_app.mathjax_url,
333 333 ))
334 334
335 335 #-----------------------------------------------------------------------------
336 336 # Kernel handlers
337 337 #-----------------------------------------------------------------------------
338 338
339 339
340 340 class MainKernelHandler(AuthenticatedHandler):
341 341
342 342 @web.authenticated
343 343 def get(self):
344 344 km = self.application.kernel_manager
345 345 self.finish(jsonapi.dumps(km.list_kernel_ids()))
346 346
347 347 @web.authenticated
348 348 def post(self):
349 349 km = self.application.kernel_manager
350 350 nbm = self.application.notebook_manager
351 351 notebook_id = self.get_argument('notebook', default=None)
352 352 kernel_id = km.start_kernel(notebook_id, cwd=nbm.notebook_dir)
353 353 data = {'ws_url':self.ws_url,'kernel_id':kernel_id}
354 354 self.set_header('Location', '/'+kernel_id)
355 355 self.finish(jsonapi.dumps(data))
356 356
357 357
358 358 class KernelHandler(AuthenticatedHandler):
359 359
360 360 SUPPORTED_METHODS = ('DELETE')
361 361
362 362 @web.authenticated
363 363 def delete(self, kernel_id):
364 364 km = self.application.kernel_manager
365 365 km.shutdown_kernel(kernel_id)
366 366 self.set_status(204)
367 367 self.finish()
368 368
369 369
370 370 class KernelActionHandler(AuthenticatedHandler):
371 371
372 372 @web.authenticated
373 373 def post(self, kernel_id, action):
374 374 km = self.application.kernel_manager
375 375 if action == 'interrupt':
376 376 km.interrupt_kernel(kernel_id)
377 377 self.set_status(204)
378 378 if action == 'restart':
379 379 km.restart_kernel(kernel_id)
380 380 data = {'ws_url':self.ws_url, 'kernel_id':kernel_id}
381 381 self.set_header('Location', '/'+kernel_id)
382 382 self.write(jsonapi.dumps(data))
383 383 self.finish()
384 384
385 385
386 386 class ZMQStreamHandler(websocket.WebSocketHandler):
387 387
388 388 def _reserialize_reply(self, msg_list):
389 389 """Reserialize a reply message using JSON.
390 390
391 391 This takes the msg list from the ZMQ socket, unserializes it using
392 392 self.session and then serializes the result using JSON. This method
393 393 should be used by self._on_zmq_reply to build messages that can
394 394 be sent back to the browser.
395 395 """
396 396 idents, msg_list = self.session.feed_identities(msg_list)
397 397 msg = self.session.unserialize(msg_list)
398 398 try:
399 399 msg['header'].pop('date')
400 400 except KeyError:
401 401 pass
402 402 try:
403 403 msg['parent_header'].pop('date')
404 404 except KeyError:
405 405 pass
406 406 msg.pop('buffers')
407 407 return jsonapi.dumps(msg, default=date_default)
408 408
409 409 def _on_zmq_reply(self, msg_list):
410 410 try:
411 411 msg = self._reserialize_reply(msg_list)
412 412 except Exception:
413 413 self.application.log.critical("Malformed message: %r" % msg_list, exc_info=True)
414 414 else:
415 415 self.write_message(msg)
416 416
417 417 def allow_draft76(self):
418 418 """Allow draft 76, until browsers such as Safari update to RFC 6455.
419 419
420 420 This has been disabled by default in tornado in release 2.2.0, and
421 421 support will be removed in later versions.
422 422 """
423 423 return True
424 424
425 425
426 426 class AuthenticatedZMQStreamHandler(ZMQStreamHandler):
427 427
428 428 def open(self, kernel_id):
429 429 self.kernel_id = kernel_id.decode('ascii')
430 430 try:
431 431 cfg = self.application.config
432 432 except AttributeError:
433 433 # protect from the case where this is run from something other than
434 434 # the notebook app:
435 435 cfg = None
436 436 self.session = Session(config=cfg)
437 437 self.save_on_message = self.on_message
438 438 self.on_message = self.on_first_message
439 439
440 440 def get_current_user(self):
441 441 user_id = self.get_secure_cookie(self.settings['cookie_name'])
442 442 if user_id == '' or (user_id is None and not self.application.password):
443 443 user_id = 'anonymous'
444 444 return user_id
445 445
446 446 def _inject_cookie_message(self, msg):
447 447 """Inject the first message, which is the document cookie,
448 448 for authentication."""
449 449 if not PY3 and isinstance(msg, unicode):
450 450 # Cookie constructor doesn't accept unicode strings
451 451 # under Python 2.x for some reason
452 452 msg = msg.encode('utf8', 'replace')
453 453 try:
454 454 self.request._cookies = Cookie.SimpleCookie(msg)
455 455 except:
456 456 logging.warn("couldn't parse cookie string: %s",msg, exc_info=True)
457 457
458 458 def on_first_message(self, msg):
459 459 self._inject_cookie_message(msg)
460 460 if self.get_current_user() is None:
461 461 logging.warn("Couldn't authenticate WebSocket connection")
462 462 raise web.HTTPError(403)
463 463 self.on_message = self.save_on_message
464 464
465 465
466 466 class IOPubHandler(AuthenticatedZMQStreamHandler):
467 467
468 468 def initialize(self, *args, **kwargs):
469 self._kernel_alive = True
470 self._beating = False
471 469 self.iopub_stream = None
472 470 self.hb_stream = None
471 self.heartbeat = None
473 472
474 473 def on_first_message(self, msg):
475 474 try:
476 475 super(IOPubHandler, self).on_first_message(msg)
477 476 except web.HTTPError:
478 477 self.close()
479 478 return
480 479 km = self.application.kernel_manager
481 self.time_to_dead = km.time_to_dead
482 self.first_beat = km.first_beat
483 480 kernel_id = self.kernel_id
484 481 try:
485 482 self.iopub_stream = km.create_iopub_stream(kernel_id)
486 483 self.hb_stream = km.create_hb_stream(kernel_id)
484 self.heartbeat = Heartbeat(
485 stream=self.hb_stream, config=self.application.config
486 )
487 487 except web.HTTPError:
488 488 # WebSockets don't response to traditional error codes so we
489 489 # close the connection.
490 490 if not self.stream.closed():
491 491 self.stream.close()
492 492 self.close()
493 493 else:
494 494 self.iopub_stream.on_recv(self._on_zmq_reply)
495 self.start_hb(self.kernel_died)
495 self.heartbeat.start(self.kernel_died)
496 496
497 497 def on_message(self, msg):
498 498 pass
499 499
500 500 def on_close(self):
501 501 # This method can be called twice, once by self.kernel_died and once
502 502 # from the WebSocket close event. If the WebSocket connection is
503 503 # closed before the ZMQ streams are setup, they could be None.
504 504 self.stop_hb()
505 505 if self.iopub_stream is not None and not self.iopub_stream.closed():
506 506 self.iopub_stream.on_recv(None)
507 507 self.iopub_stream.close()
508 508 if self.hb_stream is not None and not self.hb_stream.closed():
509 509 self.hb_stream.close()
510
511 def start_hb(self, callback):
512 """Start the heartbeating and call the callback if the kernel dies."""
513 if not self._beating:
514 self._kernel_alive = True
515
516 def ping_or_dead():
517 self.hb_stream.flush()
518 if self._kernel_alive:
519 self._kernel_alive = False
520 self.hb_stream.send(b'ping')
521 # flush stream to force immediate socket send
522 self.hb_stream.flush()
523 else:
524 try:
525 callback()
526 except:
527 pass
528 finally:
529 self.stop_hb()
530
531 def beat_received(msg):
532 self._kernel_alive = True
533
534 self.hb_stream.on_recv(beat_received)
535 loop = ioloop.IOLoop.instance()
536 self._hb_periodic_callback = ioloop.PeriodicCallback(ping_or_dead, self.time_to_dead*1000, loop)
537 loop.add_timeout(time.time()+self.first_beat, self._really_start_hb)
538 self._beating= True
539
540 def _really_start_hb(self):
541 """callback for delayed heartbeat start
542
543 Only start the hb loop if we haven't been closed during the wait.
544 """
545 if self._beating and not self.hb_stream.closed():
546 self._hb_periodic_callback.start()
547
548 def stop_hb(self):
549 """Stop the heartbeating and cancel all related callbacks."""
550 if self._beating:
551 self._beating = False
552 self._hb_periodic_callback.stop()
553 if not self.hb_stream.closed():
554 self.hb_stream.on_recv(None)
510 # stop the heartbeat here
555 511
556 512 def _delete_kernel_data(self):
557 513 """Remove the kernel data and notebook mapping."""
558 514 self.application.kernel_manager.delete_mapping_for_kernel(self.kernel_id)
559 515
560 516 def kernel_died(self):
561 517 self._delete_kernel_data()
562 518 self.application.log.error("Kernel died: %s" % self.kernel_id)
563 519 self.write_message(
564 520 {'header': {'msg_type': 'status'},
565 521 'parent_header': {},
566 522 'content': {'execution_state':'dead'}
567 523 }
568 524 )
569 525 self.on_close()
570 526
571 527
572 528 class ShellHandler(AuthenticatedZMQStreamHandler):
573 529
574 530 def initialize(self, *args, **kwargs):
575 531 self.shell_stream = None
576 532
577 533 def on_first_message(self, msg):
578 534 try:
579 535 super(ShellHandler, self).on_first_message(msg)
580 536 except web.HTTPError:
581 537 self.close()
582 538 return
583 539 km = self.application.kernel_manager
584 540 self.max_msg_size = km.max_msg_size
585 541 kernel_id = self.kernel_id
586 542 try:
587 543 self.shell_stream = km.create_shell_stream(kernel_id)
588 544 except web.HTTPError:
589 545 # WebSockets don't response to traditional error codes so we
590 546 # close the connection.
591 547 if not self.stream.closed():
592 548 self.stream.close()
593 549 self.close()
594 550 else:
595 551 self.shell_stream.on_recv(self._on_zmq_reply)
596 552
597 553 def on_message(self, msg):
598 554 if len(msg) < self.max_msg_size:
599 555 msg = jsonapi.loads(msg)
600 556 self.session.send(self.shell_stream, msg)
601 557
602 558 def on_close(self):
603 559 # Make sure the stream exists and is not already closed.
604 560 if self.shell_stream is not None and not self.shell_stream.closed():
605 561 self.shell_stream.close()
606 562
607 563
608 564 #-----------------------------------------------------------------------------
609 565 # Notebook web service handlers
610 566 #-----------------------------------------------------------------------------
611 567
612 568 class NotebookRedirectHandler(AuthenticatedHandler):
613 569
614 570 @authenticate_unless_readonly
615 571 def get(self, notebook_name):
616 572 app = self.application
617 573 # strip trailing .ipynb:
618 574 notebook_name = os.path.splitext(notebook_name)[0]
619 575 notebook_id = app.notebook_manager.rev_mapping.get(notebook_name, '')
620 576 if notebook_id:
621 577 url = self.settings.get('base_project_url', '/') + notebook_id
622 578 return self.redirect(url)
623 579 else:
624 580 raise HTTPError(404)
625 581
626 582 class NotebookRootHandler(AuthenticatedHandler):
627 583
628 584 @authenticate_unless_readonly
629 585 def get(self):
630 586 nbm = self.application.notebook_manager
631 587 km = self.application.kernel_manager
632 588 files = nbm.list_notebooks()
633 589 for f in files :
634 590 f['kernel_id'] = km.kernel_for_notebook(f['notebook_id'])
635 591 self.finish(jsonapi.dumps(files))
636 592
637 593 @web.authenticated
638 594 def post(self):
639 595 nbm = self.application.notebook_manager
640 596 body = self.request.body.strip()
641 597 format = self.get_argument('format', default='json')
642 598 name = self.get_argument('name', default=None)
643 599 if body:
644 600 notebook_id = nbm.save_new_notebook(body, name=name, format=format)
645 601 else:
646 602 notebook_id = nbm.new_notebook()
647 603 self.set_header('Location', '/'+notebook_id)
648 604 self.finish(jsonapi.dumps(notebook_id))
649 605
650 606
651 607 class NotebookHandler(AuthenticatedHandler):
652 608
653 609 SUPPORTED_METHODS = ('GET', 'PUT', 'DELETE')
654 610
655 611 @authenticate_unless_readonly
656 612 def get(self, notebook_id):
657 613 nbm = self.application.notebook_manager
658 614 format = self.get_argument('format', default='json')
659 615 last_mod, name, data = nbm.get_notebook(notebook_id, format)
660 616
661 617 if format == u'json':
662 618 self.set_header('Content-Type', 'application/json')
663 619 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
664 620 elif format == u'py':
665 621 self.set_header('Content-Type', 'application/x-python')
666 622 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
667 623 self.set_header('Last-Modified', last_mod)
668 624 self.finish(data)
669 625
670 626 @web.authenticated
671 627 def put(self, notebook_id):
672 628 nbm = self.application.notebook_manager
673 629 format = self.get_argument('format', default='json')
674 630 name = self.get_argument('name', default=None)
675 631 nbm.save_notebook(notebook_id, self.request.body, name=name, format=format)
676 632 self.set_status(204)
677 633 self.finish()
678 634
679 635 @web.authenticated
680 636 def delete(self, notebook_id):
681 637 nbm = self.application.notebook_manager
682 638 nbm.delete_notebook(notebook_id)
683 639 self.set_status(204)
684 640 self.finish()
685 641
686 642
687 643 class NotebookCopyHandler(AuthenticatedHandler):
688 644
689 645 @web.authenticated
690 646 def get(self, notebook_id):
691 647 nbm = self.application.notebook_manager
692 648 project = nbm.notebook_dir
693 649 notebook_id = nbm.copy_notebook(notebook_id)
694 650 self.redirect('/'+urljoin(self.application.ipython_app.base_project_url, notebook_id))
695 651
696 652
697 653 #-----------------------------------------------------------------------------
698 654 # Cluster handlers
699 655 #-----------------------------------------------------------------------------
700 656
701 657
702 658 class MainClusterHandler(AuthenticatedHandler):
703 659
704 660 @web.authenticated
705 661 def get(self):
706 662 cm = self.application.cluster_manager
707 663 self.finish(jsonapi.dumps(cm.list_profiles()))
708 664
709 665
710 666 class ClusterProfileHandler(AuthenticatedHandler):
711 667
712 668 @web.authenticated
713 669 def get(self, profile):
714 670 cm = self.application.cluster_manager
715 671 self.finish(jsonapi.dumps(cm.profile_info(profile)))
716 672
717 673
718 674 class ClusterActionHandler(AuthenticatedHandler):
719 675
720 676 @web.authenticated
721 677 def post(self, profile, action):
722 678 cm = self.application.cluster_manager
723 679 if action == 'start':
724 680 n = self.get_argument('n',default=None)
725 681 if n is None:
726 682 data = cm.start_cluster(profile)
727 683 else:
728 684 data = cm.start_cluster(profile,int(n))
729 685 if action == 'stop':
730 686 data = cm.stop_cluster(profile)
731 687 self.finish(jsonapi.dumps(data))
732 688
733 689
734 690 #-----------------------------------------------------------------------------
735 691 # RST web service handlers
736 692 #-----------------------------------------------------------------------------
737 693
738 694
739 695 class RSTHandler(AuthenticatedHandler):
740 696
741 697 @web.authenticated
742 698 def post(self):
743 699 if publish_string is None:
744 700 raise web.HTTPError(503, u'docutils not available')
745 701 body = self.request.body.strip()
746 702 source = body
747 703 # template_path=os.path.join(os.path.dirname(__file__), u'templates', u'rst_template.html')
748 704 defaults = {'file_insertion_enabled': 0,
749 705 'raw_enabled': 0,
750 706 '_disable_config': 1,
751 707 'stylesheet_path': 0
752 708 # 'template': template_path
753 709 }
754 710 try:
755 711 html = publish_string(source, writer_name='html',
756 712 settings_overrides=defaults
757 713 )
758 714 except:
759 715 raise web.HTTPError(400, u'Invalid RST')
760 716 print html
761 717 self.set_header('Content-Type', 'text/html')
762 718 self.finish(html)
763 719
764 720 # to minimize subclass changes:
765 721 HTTPError = web.HTTPError
766 722
767 723 class FileFindHandler(web.StaticFileHandler):
768 724 """subclass of StaticFileHandler for serving files from a search path"""
769 725
770 726 _static_paths = {}
771 727 # _lock is needed for tornado < 2.2.0 compat
772 728 _lock = threading.Lock() # protects _static_hashes
773 729
774 730 def initialize(self, path, default_filename=None):
775 731 if isinstance(path, basestring):
776 732 path = [path]
777 733 self.roots = tuple(
778 734 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
779 735 )
780 736 self.default_filename = default_filename
781 737
782 738 @classmethod
783 739 def locate_file(cls, path, roots):
784 740 """locate a file to serve on our static file search path"""
785 741 with cls._lock:
786 742 if path in cls._static_paths:
787 743 return cls._static_paths[path]
788 744 try:
789 745 abspath = os.path.abspath(filefind(path, roots))
790 746 except IOError:
791 747 # empty string should always give exists=False
792 748 return ''
793 749
794 750 # os.path.abspath strips a trailing /
795 751 # it needs to be temporarily added back for requests to root/
796 752 if not (abspath + os.path.sep).startswith(roots):
797 753 raise HTTPError(403, "%s is not in root static directory", path)
798 754
799 755 cls._static_paths[path] = abspath
800 756 return abspath
801 757
802 758 def get(self, path, include_body=True):
803 759 path = self.parse_url_path(path)
804 760
805 761 # begin subclass override
806 762 abspath = self.locate_file(path, self.roots)
807 763 # end subclass override
808 764
809 765 if os.path.isdir(abspath) and self.default_filename is not None:
810 766 # need to look at the request.path here for when path is empty
811 767 # but there is some prefix to the path that was already
812 768 # trimmed by the routing
813 769 if not self.request.path.endswith("/"):
814 770 self.redirect(self.request.path + "/")
815 771 return
816 772 abspath = os.path.join(abspath, self.default_filename)
817 773 if not os.path.exists(abspath):
818 774 raise HTTPError(404)
819 775 if not os.path.isfile(abspath):
820 776 raise HTTPError(403, "%s is not a file", path)
821 777
822 778 stat_result = os.stat(abspath)
823 779 modified = datetime.datetime.fromtimestamp(stat_result[stat.ST_MTIME])
824 780
825 781 self.set_header("Last-Modified", modified)
826 782
827 783 mime_type, encoding = mimetypes.guess_type(abspath)
828 784 if mime_type:
829 785 self.set_header("Content-Type", mime_type)
830 786
831 787 cache_time = self.get_cache_time(path, modified, mime_type)
832 788
833 789 if cache_time > 0:
834 790 self.set_header("Expires", datetime.datetime.utcnow() + \
835 791 datetime.timedelta(seconds=cache_time))
836 792 self.set_header("Cache-Control", "max-age=" + str(cache_time))
837 793 else:
838 794 self.set_header("Cache-Control", "public")
839 795
840 796 self.set_extra_headers(path)
841 797
842 798 # Check the If-Modified-Since, and don't send the result if the
843 799 # content has not been modified
844 800 ims_value = self.request.headers.get("If-Modified-Since")
845 801 if ims_value is not None:
846 802 date_tuple = email.utils.parsedate(ims_value)
847 803 if_since = datetime.datetime.fromtimestamp(time.mktime(date_tuple))
848 804 if if_since >= modified:
849 805 self.set_status(304)
850 806 return
851 807
852 808 with open(abspath, "rb") as file:
853 809 data = file.read()
854 810 hasher = hashlib.sha1()
855 811 hasher.update(data)
856 812 self.set_header("Etag", '"%s"' % hasher.hexdigest())
857 813 if include_body:
858 814 self.write(data)
859 815 else:
860 816 assert self.request.method == "HEAD"
861 817 self.set_header("Content-Length", len(data))
862 818
863 819 @classmethod
864 820 def get_version(cls, settings, path):
865 821 """Generate the version string to be used in static URLs.
866 822
867 823 This method may be overridden in subclasses (but note that it
868 824 is a class method rather than a static method). The default
869 825 implementation uses a hash of the file's contents.
870 826
871 827 ``settings`` is the `Application.settings` dictionary and ``path``
872 828 is the relative location of the requested asset on the filesystem.
873 829 The returned value should be a string, or ``None`` if no version
874 830 could be determined.
875 831 """
876 832 # begin subclass override:
877 833 static_paths = settings['static_path']
878 834 if isinstance(static_paths, basestring):
879 835 static_paths = [static_paths]
880 836 roots = tuple(
881 837 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
882 838 )
883 839
884 840 try:
885 841 abs_path = filefind(path, roots)
886 842 except IOError:
887 843 logging.error("Could not find static file %r", path)
888 844 return None
889 845
890 846 # end subclass override
891 847
892 848 with cls._lock:
893 849 hashes = cls._static_hashes
894 850 if abs_path not in hashes:
895 851 try:
896 852 f = open(abs_path, "rb")
897 853 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
898 854 f.close()
899 855 except Exception:
900 856 logging.error("Could not open static file %r", path)
901 857 hashes[abs_path] = None
902 858 hsh = hashes.get(abs_path)
903 859 if hsh:
904 860 return hsh[:5]
905 861 return None
906 862
907 863
908 864 def parse_url_path(self, url_path):
909 865 """Converts a static URL path into a filesystem path.
910 866
911 867 ``url_path`` is the path component of the URL with
912 868 ``static_url_prefix`` removed. The return value should be
913 869 filesystem path relative to ``static_path``.
914 870 """
915 871 if os.path.sep != "/":
916 872 url_path = url_path.replace("/", os.path.sep)
917 873 return url_path
918 874
919 875
@@ -1,133 +1,134 b''
1 1 """A kernel manager relating notebooks and kernels
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2013 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19 from tornado import web
20 20
21 21 from IPython.kernel.multikernelmanager import MultiKernelManager
22 22 from IPython.utils.traitlets import (
23 23 Dict, List, Unicode, Float, Integer,
24 24 )
25
25 26 #-----------------------------------------------------------------------------
26 27 # Classes
27 28 #-----------------------------------------------------------------------------
28 29
29 30
30 31 class MappingKernelManager(MultiKernelManager):
31 32 """A KernelManager that handles notebok mapping and HTTP error handling"""
32 33
33 34 kernel_argv = List(Unicode)
34 35
35 36 time_to_dead = Float(3.0, config=True, help="""Kernel heartbeat interval in seconds.""")
36 37 first_beat = Float(5.0, config=True, help="Delay (in seconds) before sending first heartbeat.")
37 38
38 39 max_msg_size = Integer(65536, config=True, help="""
39 40 The max raw message size accepted from the browser
40 41 over a WebSocket connection.
41 42 """)
42 43
43 44 _notebook_mapping = Dict()
44 45
45 46 #-------------------------------------------------------------------------
46 47 # Methods for managing kernels and sessions
47 48 #-------------------------------------------------------------------------
48 49
49 50 def kernel_for_notebook(self, notebook_id):
50 51 """Return the kernel_id for a notebook_id or None."""
51 52 return self._notebook_mapping.get(notebook_id)
52 53
53 54 def set_kernel_for_notebook(self, notebook_id, kernel_id):
54 55 """Associate a notebook with a kernel."""
55 56 if notebook_id is not None:
56 57 self._notebook_mapping[notebook_id] = kernel_id
57 58
58 59 def notebook_for_kernel(self, kernel_id):
59 60 """Return the notebook_id for a kernel_id or None."""
60 61 notebook_ids = [k for k, v in self._notebook_mapping.iteritems() if v == kernel_id]
61 62 if len(notebook_ids) == 1:
62 63 return notebook_ids[0]
63 64 else:
64 65 return None
65 66
66 67 def delete_mapping_for_kernel(self, kernel_id):
67 68 """Remove the kernel/notebook mapping for kernel_id."""
68 69 notebook_id = self.notebook_for_kernel(kernel_id)
69 70 if notebook_id is not None:
70 71 del self._notebook_mapping[notebook_id]
71 72
72 73 def start_kernel(self, notebook_id=None, **kwargs):
73 74 """Start a kernel for a notebok an return its kernel_id.
74 75
75 76 Parameters
76 77 ----------
77 78 notebook_id : uuid
78 79 The uuid of the notebook to associate the new kernel with. If this
79 80 is not None, this kernel will be persistent whenever the notebook
80 81 requests a kernel.
81 82 """
82 83 kernel_id = self.kernel_for_notebook(notebook_id)
83 84 if kernel_id is None:
84 85 kwargs['extra_arguments'] = self.kernel_argv
85 86 kernel_id = super(MappingKernelManager, self).start_kernel(**kwargs)
86 87 self.set_kernel_for_notebook(notebook_id, kernel_id)
87 88 self.log.info("Kernel started: %s" % kernel_id)
88 89 self.log.debug("Kernel args: %r" % kwargs)
89 90 else:
90 91 self.log.info("Using existing kernel: %s" % kernel_id)
91 92 return kernel_id
92 93
93 94 def shutdown_kernel(self, kernel_id, now=False):
94 95 """Shutdown a kernel and remove its notebook association."""
95 96 self._check_kernel_id(kernel_id)
96 97 super(MappingKernelManager, self).shutdown_kernel(
97 98 kernel_id, now=now
98 99 )
99 100 self.delete_mapping_for_kernel(kernel_id)
100 101 self.log.info("Kernel shutdown: %s" % kernel_id)
101 102
102 103 def interrupt_kernel(self, kernel_id):
103 104 """Interrupt a kernel."""
104 105 self._check_kernel_id(kernel_id)
105 106 super(MappingKernelManager, self).interrupt_kernel(kernel_id)
106 107 self.log.info("Kernel interrupted: %s" % kernel_id)
107 108
108 109 def restart_kernel(self, kernel_id):
109 110 """Restart a kernel while keeping clients connected."""
110 111 self._check_kernel_id(kernel_id)
111 112 super(MappingKernelManager, self).restart_kernel(kernel_id)
112 113 self.log.info("Kernel restarted: %s" % kernel_id)
113 114
114 115 def create_iopub_stream(self, kernel_id):
115 116 """Create a new iopub stream."""
116 117 self._check_kernel_id(kernel_id)
117 118 return super(MappingKernelManager, self).create_iopub_stream(kernel_id)
118 119
119 120 def create_shell_stream(self, kernel_id):
120 121 """Create a new shell stream."""
121 122 self._check_kernel_id(kernel_id)
122 123 return super(MappingKernelManager, self).create_shell_stream(kernel_id)
123 124
124 125 def create_hb_stream(self, kernel_id):
125 126 """Create a new hb stream."""
126 127 self._check_kernel_id(kernel_id)
127 128 return super(MappingKernelManager, self).create_hb_stream(kernel_id)
128 129
129 130 def _check_kernel_id(self, kernel_id):
130 131 """Check a that a kernel_id exists and raise 404 if not."""
131 132 if kernel_id not in self:
132 133 raise web.HTTPError(404, u'Kernel does not exist: %s' % kernel_id)
133 134
General Comments 0
You need to be logged in to leave comments. Login now