##// 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,4 +1,4 b''
1 """Tornado handlers for the notebook.
1 """Base Tornado handlers for the notebook.
2 2
3 3 Authors:
4 4
@@ -6,7 +6,7 b' Authors:'
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 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.
@@ -16,19 +16,8 b' Authors:'
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 19 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
20
32 21 from tornado import web
33 22 from tornado import websocket
34 23
@@ -37,21 +26,8 b' try:'
37 26 except ImportError:
38 27 app_log = logging.getLogger()
39 28
40 from zmq.eventloop import ioloop
41 from zmq.utils import jsonapi
42
43 29 from IPython.config import Application
44 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 33 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
@@ -131,14 +107,6 b' def authenticate_unless_readonly(f, self, *args, **kwargs):'
131 107 else:
132 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 111 # Top-level handlers
144 112 #-----------------------------------------------------------------------------
@@ -306,626 +274,3 b' class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):'
306 274 @authenticate_unless_readonly
307 275 def get(self, path):
308 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,4 +1,4 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for cluster web service.
2 2
3 3 Authors:
4 4
@@ -6,7 +6,7 b' Authors:'
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 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.
@@ -16,723 +16,11 b' Authors:'
16 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 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 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
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
23 from .base import IPythonHandler
736 24
737 25 #-----------------------------------------------------------------------------
738 26 # Cluster handlers
@@ -767,165 +55,3 b' class ClusterActionHandler(IPythonHandler):'
767 55 if action == 'stop':
768 56 data = cm.stop_cluster(profile)
769 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
@@ -17,368 +17,17 b' Authors:'
17 17 #-----------------------------------------------------------------------------
18 18
19 19 import Cookie
20 import datetime
21 import email.utils
22 import hashlib
23 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 21 from tornado import web
33 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 24 from zmq.utils import jsonapi
42 25
43 from IPython.config import Application
44 from IPython.external.decorator import decorator
45 26 from IPython.kernel.zmq.session import Session
46 from IPython.lib.security import passwd_check
47 27 from IPython.utils.jsonutil import date_default
48 from IPython.utils.path import filefind
49 28 from IPython.utils.py3compat import PY3
50 29
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
30 from .base import IPythonHandler
382 31
383 32 #-----------------------------------------------------------------------------
384 33 # Kernel handlers
@@ -600,332 +249,3 b' class ShellHandler(ZMQChannelHandler):'
600 249
601 250 class StdinHandler(ZMQChannelHandler):
602 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,4 +1,4 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers logging into the notebook.
2 2
3 3 Authors:
4 4
@@ -6,7 +6,7 b' Authors:'
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 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.
@@ -16,307 +16,18 b' Authors:'
16 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 19 import uuid
30 20
31 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 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
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)
25 from .base import IPythonHandler
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 32 class LoginHandler(IPythonHandler):
322 33
@@ -344,588 +55,3 b' class LoginHandler(IPythonHandler):'
344 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,4 +1,4 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for logging out of the notebook.
2 2
3 3 Authors:
4 4
@@ -6,7 +6,7 b' Authors:'
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 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.
@@ -16,333 +16,12 b' Authors:'
16 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
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)
19 from .base import IPythonHandler
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 26 class LogoutHandler(IPythonHandler):
348 27
@@ -355,577 +34,3 b' class LogoutHandler(IPythonHandler):'
355 34 'is disabled.'}
356 35 self.write(self.render_template('logout.html',
357 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,4 +1,4 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for the live notebook view.
2 2
3 3 Authors:
4 4
@@ -6,7 +6,7 b' Authors:'
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 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.
@@ -16,353 +16,25 b' Authors:'
16 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 19 import os
26 import stat
27 import threading
28 import time
29 import uuid
30
31 from tornado.escape import url_escape
32 20 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
21 HTTPError = web.HTTPError
136 22
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)
23 from .base import IPythonHandler, authenticate_unless_readonly
24 from ..utils import url_path_join
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 31 class NewHandler(IPythonHandler):
361 32
362 33 @web.authenticated
363 34 def get(self):
364 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 39 class NamedNotebookHandler(IPythonHandler):
368 40
@@ -380,232 +52,6 b' class NamedNotebookHandler(IPythonHandler):'
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 55 class NotebookRedirectHandler(IPythonHandler):
610 56
611 57 @authenticate_unless_readonly
@@ -620,312 +66,10 b' class NotebookRedirectHandler(IPythonHandler):'
620 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 69 class NotebookCopyHandler(IPythonHandler):
730 70
731 71 @web.authenticated
732 72 def get(self, notebook_id):
733 73 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
74 self.redirect('/'+url_path_join(self.base_project_url, notebook_id))
931 75
This diff has been collapsed as it changes many lines, (797 lines changed) Show them Hide them
@@ -1,4 +1,4 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for the notebooks web service.
2 2
3 3 Authors:
4 4
@@ -16,610 +16,18 b' Authors:'
16 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 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 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 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 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 31 class NotebookRootHandler(IPythonHandler):
624 32
625 33 @authenticate_unless_readonly
@@ -726,206 +134,5 b' class ModifyNotebookCheckpointsHandler(IPythonHandler):'
726 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,4 +1,4 b''
1 """Tornado handlers for the notebook.
1 """Tornado handlers for the tree view.
2 2
3 3 Authors:
4 4
@@ -6,7 +6,7 b' Authors:'
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 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.
@@ -16,297 +16,12 b' Authors:'
16 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
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)
19 from .base import IPythonHandler, authenticate_unless_readonly
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 26 class ProjectDashboardHandler(IPythonHandler):
312 27
@@ -316,616 +31,3 b' class ProjectDashboardHandler(IPythonHandler):'
316 31 project=self.project,
317 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
@@ -21,7 +21,6 b' import errno'
21 21 import logging
22 22 import os
23 23 import random
24 import re
25 24 import select
26 25 import signal
27 26 import socket
@@ -64,40 +63,54 b' from tornado import web'
64 63 # Our own libraries
65 64 from IPython.frontend.html.notebook import DEFAULT_STATIC_FILES_PATH
66 65 from .kernelmanager import MappingKernelManager
67 from .handlers import (LoginHandler, LogoutHandler,
68 ProjectDashboardHandler, NewHandler, NamedNotebookHandler,
69 MainKernelHandler, KernelHandler, KernelActionHandler, IOPubHandler, StdinHandler,
70 ShellHandler, NotebookRootHandler, NotebookHandler, NotebookCopyHandler,
71 NotebookRedirectHandler, NotebookCheckpointsHandler, ModifyNotebookCheckpointsHandler,
72 AuthenticatedFileHandler, FileFindHandler,
73 MainClusterHandler, ClusterProfileHandler, ClusterActionHandler,
66
67 from .handlers.clustersapi import (
68 MainClusterHandler, ClusterProfileHandler, ClusterActionHandler
69 )
70 from .handlers.kernelsapi import (
71 MainKernelHandler, KernelHandler, KernelActionHandler,
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 89 from .nbmanager import NotebookManager
76 90 from .filenbmanager import FileNotebookManager
77 91 from .clustermanager import ClusterManager
78 92
79 93 from IPython.config.application import catch_config_error, boolean_flag
80 94 from IPython.core.application import BaseIPythonApplication
81 from IPython.core.profiledir import ProfileDir
82 95 from IPython.frontend.consoleapp import IPythonConsoleApp
83 96 from IPython.kernel import swallow_argv
84 from IPython.kernel.zmq.session import Session, default_secure
85 from IPython.kernel.zmq.zmqshell import ZMQInteractiveShell
97 from IPython.kernel.zmq.session import default_secure
86 98 from IPython.kernel.zmq.kernelapp import (
87 99 kernel_flags,
88 100 kernel_aliases,
89 IPKernelApp
90 101 )
91 102 from IPython.utils.importstring import import_item
92 103 from IPython.utils.localinterfaces import LOCALHOST
93 104 from IPython.utils import submodule
94 105 from IPython.utils.traitlets import (
95 Dict, Unicode, Integer, List, Enum, Bool,
106 Dict, Unicode, Integer, List, Bool,
96 107 DottedObjectName
97 108 )
98 109 from IPython.utils import py3compat
99 110 from IPython.utils.path import filefind
100 111
112 from .utils import url_path_join
113
101 114 #-----------------------------------------------------------------------------
102 115 # Module globals
103 116 #-----------------------------------------------------------------------------
@@ -122,12 +135,6 b' ipython notebook --port=5555 --ip=* # Listen on port 5555, all interfaces'
122 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 138 def random_ports(port, n):
132 139 """Generate a list of n random ports near the given port.
133 140
1 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