##// END OF EJS Templates
Merge pull request #7253 from minrk/async-contents-handlers...
Thomas Kluyver -
r19602:d994c955 merge
parent child Browse files
Show More
@@ -1,508 +1,509
1 """Base Tornado handlers for the notebook server."""
1 """Base Tornado handlers for the notebook server."""
2
2
3 # Copyright (c) IPython Development Team.
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
4 # Distributed under the terms of the Modified BSD License.
5
5
6 import functools
6 import functools
7 import json
7 import json
8 import logging
8 import logging
9 import os
9 import os
10 import re
10 import re
11 import sys
11 import sys
12 import traceback
12 import traceback
13 try:
13 try:
14 # py3
14 # py3
15 from http.client import responses
15 from http.client import responses
16 except ImportError:
16 except ImportError:
17 from httplib import responses
17 from httplib import responses
18
18
19 from jinja2 import TemplateNotFound
19 from jinja2 import TemplateNotFound
20 from tornado import web
20 from tornado import web
21
21
22 try:
22 from tornado import gen
23 from tornado.log import app_log
23 from tornado.log import app_log
24 except ImportError:
24
25 app_log = logging.getLogger()
26
25
27 import IPython
26 import IPython
28 from IPython.utils.sysinfo import get_sys_info
27 from IPython.utils.sysinfo import get_sys_info
29
28
30 from IPython.config import Application
29 from IPython.config import Application
31 from IPython.utils.path import filefind
30 from IPython.utils.path import filefind
32 from IPython.utils.py3compat import string_types
31 from IPython.utils.py3compat import string_types
33 from IPython.html.utils import is_hidden, url_path_join, url_escape
32 from IPython.html.utils import is_hidden, url_path_join, url_escape
34
33
35 from IPython.html.services.security import csp_report_uri
34 from IPython.html.services.security import csp_report_uri
36
35
37 #-----------------------------------------------------------------------------
36 #-----------------------------------------------------------------------------
38 # Top-level handlers
37 # Top-level handlers
39 #-----------------------------------------------------------------------------
38 #-----------------------------------------------------------------------------
40 non_alphanum = re.compile(r'[^A-Za-z0-9]')
39 non_alphanum = re.compile(r'[^A-Za-z0-9]')
41
40
42 sys_info = json.dumps(get_sys_info())
41 sys_info = json.dumps(get_sys_info())
43
42
44 class AuthenticatedHandler(web.RequestHandler):
43 class AuthenticatedHandler(web.RequestHandler):
45 """A RequestHandler with an authenticated user."""
44 """A RequestHandler with an authenticated user."""
46
45
47 def set_default_headers(self):
46 def set_default_headers(self):
48 headers = self.settings.get('headers', {})
47 headers = self.settings.get('headers', {})
49
48
50 if "Content-Security-Policy" not in headers:
49 if "Content-Security-Policy" not in headers:
51 headers["Content-Security-Policy"] = (
50 headers["Content-Security-Policy"] = (
52 "frame-ancestors 'self'; "
51 "frame-ancestors 'self'; "
53 # Make sure the report-uri is relative to the base_url
52 # Make sure the report-uri is relative to the base_url
54 "report-uri " + url_path_join(self.base_url, csp_report_uri) + ";"
53 "report-uri " + url_path_join(self.base_url, csp_report_uri) + ";"
55 )
54 )
56
55
57 # Allow for overriding headers
56 # Allow for overriding headers
58 for header_name,value in headers.items() :
57 for header_name,value in headers.items() :
59 try:
58 try:
60 self.set_header(header_name, value)
59 self.set_header(header_name, value)
61 except Exception as e:
60 except Exception as e:
62 # tornado raise Exception (not a subclass)
61 # tornado raise Exception (not a subclass)
63 # if method is unsupported (websocket and Access-Control-Allow-Origin
62 # if method is unsupported (websocket and Access-Control-Allow-Origin
64 # for example, so just ignore)
63 # for example, so just ignore)
65 self.log.debug(e)
64 self.log.debug(e)
66
65
67 def clear_login_cookie(self):
66 def clear_login_cookie(self):
68 self.clear_cookie(self.cookie_name)
67 self.clear_cookie(self.cookie_name)
69
68
70 def get_current_user(self):
69 def get_current_user(self):
71 if self.login_handler is None:
70 if self.login_handler is None:
72 return 'anonymous'
71 return 'anonymous'
73 return self.login_handler.get_user(self)
72 return self.login_handler.get_user(self)
74
73
75 @property
74 @property
76 def cookie_name(self):
75 def cookie_name(self):
77 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
76 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
78 self.request.host
77 self.request.host
79 ))
78 ))
80 return self.settings.get('cookie_name', default_cookie_name)
79 return self.settings.get('cookie_name', default_cookie_name)
81
80
82 @property
81 @property
83 def logged_in(self):
82 def logged_in(self):
84 """Is a user currently logged in?"""
83 """Is a user currently logged in?"""
85 user = self.get_current_user()
84 user = self.get_current_user()
86 return (user and not user == 'anonymous')
85 return (user and not user == 'anonymous')
87
86
88 @property
87 @property
89 def login_handler(self):
88 def login_handler(self):
90 """Return the login handler for this application, if any."""
89 """Return the login handler for this application, if any."""
91 return self.settings.get('login_handler_class', None)
90 return self.settings.get('login_handler_class', None)
92
91
93 @property
92 @property
94 def login_available(self):
93 def login_available(self):
95 """May a user proceed to log in?
94 """May a user proceed to log in?
96
95
97 This returns True if login capability is available, irrespective of
96 This returns True if login capability is available, irrespective of
98 whether the user is already logged in or not.
97 whether the user is already logged in or not.
99
98
100 """
99 """
101 if self.login_handler is None:
100 if self.login_handler is None:
102 return False
101 return False
103 return bool(self.login_handler.login_available(self.settings))
102 return bool(self.login_handler.login_available(self.settings))
104
103
105
104
106 class IPythonHandler(AuthenticatedHandler):
105 class IPythonHandler(AuthenticatedHandler):
107 """IPython-specific extensions to authenticated handling
106 """IPython-specific extensions to authenticated handling
108
107
109 Mostly property shortcuts to IPython-specific settings.
108 Mostly property shortcuts to IPython-specific settings.
110 """
109 """
111
110
112 @property
111 @property
113 def config(self):
112 def config(self):
114 return self.settings.get('config', None)
113 return self.settings.get('config', None)
115
114
116 @property
115 @property
117 def log(self):
116 def log(self):
118 """use the IPython log by default, falling back on tornado's logger"""
117 """use the IPython log by default, falling back on tornado's logger"""
119 if Application.initialized():
118 if Application.initialized():
120 return Application.instance().log
119 return Application.instance().log
121 else:
120 else:
122 return app_log
121 return app_log
123
122
124 #---------------------------------------------------------------
123 #---------------------------------------------------------------
125 # URLs
124 # URLs
126 #---------------------------------------------------------------
125 #---------------------------------------------------------------
127
126
128 @property
127 @property
129 def version_hash(self):
128 def version_hash(self):
130 """The version hash to use for cache hints for static files"""
129 """The version hash to use for cache hints for static files"""
131 return self.settings.get('version_hash', '')
130 return self.settings.get('version_hash', '')
132
131
133 @property
132 @property
134 def mathjax_url(self):
133 def mathjax_url(self):
135 return self.settings.get('mathjax_url', '')
134 return self.settings.get('mathjax_url', '')
136
135
137 @property
136 @property
138 def base_url(self):
137 def base_url(self):
139 return self.settings.get('base_url', '/')
138 return self.settings.get('base_url', '/')
140
139
141 @property
140 @property
142 def ws_url(self):
141 def ws_url(self):
143 return self.settings.get('websocket_url', '')
142 return self.settings.get('websocket_url', '')
144
143
145 @property
144 @property
146 def contents_js_source(self):
145 def contents_js_source(self):
147 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
146 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
148 'services/contents'))
147 'services/contents'))
149 return self.settings.get('contents_js_source', 'services/contents')
148 return self.settings.get('contents_js_source', 'services/contents')
150
149
151 #---------------------------------------------------------------
150 #---------------------------------------------------------------
152 # Manager objects
151 # Manager objects
153 #---------------------------------------------------------------
152 #---------------------------------------------------------------
154
153
155 @property
154 @property
156 def kernel_manager(self):
155 def kernel_manager(self):
157 return self.settings['kernel_manager']
156 return self.settings['kernel_manager']
158
157
159 @property
158 @property
160 def contents_manager(self):
159 def contents_manager(self):
161 return self.settings['contents_manager']
160 return self.settings['contents_manager']
162
161
163 @property
162 @property
164 def cluster_manager(self):
163 def cluster_manager(self):
165 return self.settings['cluster_manager']
164 return self.settings['cluster_manager']
166
165
167 @property
166 @property
168 def session_manager(self):
167 def session_manager(self):
169 return self.settings['session_manager']
168 return self.settings['session_manager']
170
169
171 @property
170 @property
172 def terminal_manager(self):
171 def terminal_manager(self):
173 return self.settings['terminal_manager']
172 return self.settings['terminal_manager']
174
173
175 @property
174 @property
176 def kernel_spec_manager(self):
175 def kernel_spec_manager(self):
177 return self.settings['kernel_spec_manager']
176 return self.settings['kernel_spec_manager']
178
177
179 @property
178 @property
180 def config_manager(self):
179 def config_manager(self):
181 return self.settings['config_manager']
180 return self.settings['config_manager']
182
181
183 #---------------------------------------------------------------
182 #---------------------------------------------------------------
184 # CORS
183 # CORS
185 #---------------------------------------------------------------
184 #---------------------------------------------------------------
186
185
187 @property
186 @property
188 def allow_origin(self):
187 def allow_origin(self):
189 """Normal Access-Control-Allow-Origin"""
188 """Normal Access-Control-Allow-Origin"""
190 return self.settings.get('allow_origin', '')
189 return self.settings.get('allow_origin', '')
191
190
192 @property
191 @property
193 def allow_origin_pat(self):
192 def allow_origin_pat(self):
194 """Regular expression version of allow_origin"""
193 """Regular expression version of allow_origin"""
195 return self.settings.get('allow_origin_pat', None)
194 return self.settings.get('allow_origin_pat', None)
196
195
197 @property
196 @property
198 def allow_credentials(self):
197 def allow_credentials(self):
199 """Whether to set Access-Control-Allow-Credentials"""
198 """Whether to set Access-Control-Allow-Credentials"""
200 return self.settings.get('allow_credentials', False)
199 return self.settings.get('allow_credentials', False)
201
200
202 def set_default_headers(self):
201 def set_default_headers(self):
203 """Add CORS headers, if defined"""
202 """Add CORS headers, if defined"""
204 super(IPythonHandler, self).set_default_headers()
203 super(IPythonHandler, self).set_default_headers()
205 if self.allow_origin:
204 if self.allow_origin:
206 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
205 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
207 elif self.allow_origin_pat:
206 elif self.allow_origin_pat:
208 origin = self.get_origin()
207 origin = self.get_origin()
209 if origin and self.allow_origin_pat.match(origin):
208 if origin and self.allow_origin_pat.match(origin):
210 self.set_header("Access-Control-Allow-Origin", origin)
209 self.set_header("Access-Control-Allow-Origin", origin)
211 if self.allow_credentials:
210 if self.allow_credentials:
212 self.set_header("Access-Control-Allow-Credentials", 'true')
211 self.set_header("Access-Control-Allow-Credentials", 'true')
213
212
214 def get_origin(self):
213 def get_origin(self):
215 # Handle WebSocket Origin naming convention differences
214 # Handle WebSocket Origin naming convention differences
216 # The difference between version 8 and 13 is that in 8 the
215 # The difference between version 8 and 13 is that in 8 the
217 # client sends a "Sec-Websocket-Origin" header and in 13 it's
216 # client sends a "Sec-Websocket-Origin" header and in 13 it's
218 # simply "Origin".
217 # simply "Origin".
219 if "Origin" in self.request.headers:
218 if "Origin" in self.request.headers:
220 origin = self.request.headers.get("Origin")
219 origin = self.request.headers.get("Origin")
221 else:
220 else:
222 origin = self.request.headers.get("Sec-Websocket-Origin", None)
221 origin = self.request.headers.get("Sec-Websocket-Origin", None)
223 return origin
222 return origin
224
223
225 #---------------------------------------------------------------
224 #---------------------------------------------------------------
226 # template rendering
225 # template rendering
227 #---------------------------------------------------------------
226 #---------------------------------------------------------------
228
227
229 def get_template(self, name):
228 def get_template(self, name):
230 """Return the jinja template object for a given name"""
229 """Return the jinja template object for a given name"""
231 return self.settings['jinja2_env'].get_template(name)
230 return self.settings['jinja2_env'].get_template(name)
232
231
233 def render_template(self, name, **ns):
232 def render_template(self, name, **ns):
234 ns.update(self.template_namespace)
233 ns.update(self.template_namespace)
235 template = self.get_template(name)
234 template = self.get_template(name)
236 return template.render(**ns)
235 return template.render(**ns)
237
236
238 @property
237 @property
239 def template_namespace(self):
238 def template_namespace(self):
240 return dict(
239 return dict(
241 base_url=self.base_url,
240 base_url=self.base_url,
242 ws_url=self.ws_url,
241 ws_url=self.ws_url,
243 logged_in=self.logged_in,
242 logged_in=self.logged_in,
244 login_available=self.login_available,
243 login_available=self.login_available,
245 static_url=self.static_url,
244 static_url=self.static_url,
246 sys_info=sys_info,
245 sys_info=sys_info,
247 contents_js_source=self.contents_js_source,
246 contents_js_source=self.contents_js_source,
248 version_hash=self.version_hash,
247 version_hash=self.version_hash,
249 )
248 )
250
249
251 def get_json_body(self):
250 def get_json_body(self):
252 """Return the body of the request as JSON data."""
251 """Return the body of the request as JSON data."""
253 if not self.request.body:
252 if not self.request.body:
254 return None
253 return None
255 # Do we need to call body.decode('utf-8') here?
254 # Do we need to call body.decode('utf-8') here?
256 body = self.request.body.strip().decode(u'utf-8')
255 body = self.request.body.strip().decode(u'utf-8')
257 try:
256 try:
258 model = json.loads(body)
257 model = json.loads(body)
259 except Exception:
258 except Exception:
260 self.log.debug("Bad JSON: %r", body)
259 self.log.debug("Bad JSON: %r", body)
261 self.log.error("Couldn't parse JSON", exc_info=True)
260 self.log.error("Couldn't parse JSON", exc_info=True)
262 raise web.HTTPError(400, u'Invalid JSON in body of request')
261 raise web.HTTPError(400, u'Invalid JSON in body of request')
263 return model
262 return model
264
263
265 def write_error(self, status_code, **kwargs):
264 def write_error(self, status_code, **kwargs):
266 """render custom error pages"""
265 """render custom error pages"""
267 exc_info = kwargs.get('exc_info')
266 exc_info = kwargs.get('exc_info')
268 message = ''
267 message = ''
269 status_message = responses.get(status_code, 'Unknown HTTP Error')
268 status_message = responses.get(status_code, 'Unknown HTTP Error')
270 if exc_info:
269 if exc_info:
271 exception = exc_info[1]
270 exception = exc_info[1]
272 # get the custom message, if defined
271 # get the custom message, if defined
273 try:
272 try:
274 message = exception.log_message % exception.args
273 message = exception.log_message % exception.args
275 except Exception:
274 except Exception:
276 pass
275 pass
277
276
278 # construct the custom reason, if defined
277 # construct the custom reason, if defined
279 reason = getattr(exception, 'reason', '')
278 reason = getattr(exception, 'reason', '')
280 if reason:
279 if reason:
281 status_message = reason
280 status_message = reason
282
281
283 # build template namespace
282 # build template namespace
284 ns = dict(
283 ns = dict(
285 status_code=status_code,
284 status_code=status_code,
286 status_message=status_message,
285 status_message=status_message,
287 message=message,
286 message=message,
288 exception=exception,
287 exception=exception,
289 )
288 )
290
289
291 self.set_header('Content-Type', 'text/html')
290 self.set_header('Content-Type', 'text/html')
292 # render the template
291 # render the template
293 try:
292 try:
294 html = self.render_template('%s.html' % status_code, **ns)
293 html = self.render_template('%s.html' % status_code, **ns)
295 except TemplateNotFound:
294 except TemplateNotFound:
296 self.log.debug("No template for %d", status_code)
295 self.log.debug("No template for %d", status_code)
297 html = self.render_template('error.html', **ns)
296 html = self.render_template('error.html', **ns)
298
297
299 self.write(html)
298 self.write(html)
300
299
301
300
302
301
303 class Template404(IPythonHandler):
302 class Template404(IPythonHandler):
304 """Render our 404 template"""
303 """Render our 404 template"""
305 def prepare(self):
304 def prepare(self):
306 raise web.HTTPError(404)
305 raise web.HTTPError(404)
307
306
308
307
309 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
308 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
310 """static files should only be accessible when logged in"""
309 """static files should only be accessible when logged in"""
311
310
312 @web.authenticated
311 @web.authenticated
313 def get(self, path):
312 def get(self, path):
314 if os.path.splitext(path)[1] == '.ipynb':
313 if os.path.splitext(path)[1] == '.ipynb':
315 name = path.rsplit('/', 1)[-1]
314 name = path.rsplit('/', 1)[-1]
316 self.set_header('Content-Type', 'application/json')
315 self.set_header('Content-Type', 'application/json')
317 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
316 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
318
317
319 return web.StaticFileHandler.get(self, path)
318 return web.StaticFileHandler.get(self, path)
320
319
321 def set_headers(self):
320 def set_headers(self):
322 super(AuthenticatedFileHandler, self).set_headers()
321 super(AuthenticatedFileHandler, self).set_headers()
323 # disable browser caching, rely on 304 replies for savings
322 # disable browser caching, rely on 304 replies for savings
324 if "v" not in self.request.arguments:
323 if "v" not in self.request.arguments:
325 self.add_header("Cache-Control", "no-cache")
324 self.add_header("Cache-Control", "no-cache")
326
325
327 def compute_etag(self):
326 def compute_etag(self):
328 return None
327 return None
329
328
330 def validate_absolute_path(self, root, absolute_path):
329 def validate_absolute_path(self, root, absolute_path):
331 """Validate and return the absolute path.
330 """Validate and return the absolute path.
332
331
333 Requires tornado 3.1
332 Requires tornado 3.1
334
333
335 Adding to tornado's own handling, forbids the serving of hidden files.
334 Adding to tornado's own handling, forbids the serving of hidden files.
336 """
335 """
337 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
336 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
338 abs_root = os.path.abspath(root)
337 abs_root = os.path.abspath(root)
339 if is_hidden(abs_path, abs_root):
338 if is_hidden(abs_path, abs_root):
340 self.log.info("Refusing to serve hidden file, via 404 Error")
339 self.log.info("Refusing to serve hidden file, via 404 Error")
341 raise web.HTTPError(404)
340 raise web.HTTPError(404)
342 return abs_path
341 return abs_path
343
342
344
343
345 def json_errors(method):
344 def json_errors(method):
346 """Decorate methods with this to return GitHub style JSON errors.
345 """Decorate methods with this to return GitHub style JSON errors.
347
346
348 This should be used on any JSON API on any handler method that can raise HTTPErrors.
347 This should be used on any JSON API on any handler method that can raise HTTPErrors.
349
348
350 This will grab the latest HTTPError exception using sys.exc_info
349 This will grab the latest HTTPError exception using sys.exc_info
351 and then:
350 and then:
352
351
353 1. Set the HTTP status code based on the HTTPError
352 1. Set the HTTP status code based on the HTTPError
354 2. Create and return a JSON body with a message field describing
353 2. Create and return a JSON body with a message field describing
355 the error in a human readable form.
354 the error in a human readable form.
356 """
355 """
357 @functools.wraps(method)
356 @functools.wraps(method)
357 @gen.coroutine
358 def wrapper(self, *args, **kwargs):
358 def wrapper(self, *args, **kwargs):
359 try:
359 try:
360 result = method(self, *args, **kwargs)
360 result = yield gen.maybe_future(method(self, *args, **kwargs))
361 except web.HTTPError as e:
361 except web.HTTPError as e:
362 status = e.status_code
362 status = e.status_code
363 message = e.log_message
363 message = e.log_message
364 self.log.warn(message)
364 self.log.warn(message)
365 self.set_status(e.status_code)
365 self.set_status(e.status_code)
366 reply = dict(message=message, reason=e.reason)
366 reply = dict(message=message, reason=e.reason)
367 self.finish(json.dumps(reply))
367 self.finish(json.dumps(reply))
368 except Exception:
368 except Exception:
369 self.log.error("Unhandled error in API request", exc_info=True)
369 self.log.error("Unhandled error in API request", exc_info=True)
370 status = 500
370 status = 500
371 message = "Unknown server error"
371 message = "Unknown server error"
372 t, value, tb = sys.exc_info()
372 t, value, tb = sys.exc_info()
373 self.set_status(status)
373 self.set_status(status)
374 tb_text = ''.join(traceback.format_exception(t, value, tb))
374 tb_text = ''.join(traceback.format_exception(t, value, tb))
375 reply = dict(message=message, reason=None, traceback=tb_text)
375 reply = dict(message=message, reason=None, traceback=tb_text)
376 self.finish(json.dumps(reply))
376 self.finish(json.dumps(reply))
377 else:
377 else:
378 return result
378 # FIXME: can use regular return in generators in py3
379 raise gen.Return(result)
379 return wrapper
380 return wrapper
380
381
381
382
382
383
383 #-----------------------------------------------------------------------------
384 #-----------------------------------------------------------------------------
384 # File handler
385 # File handler
385 #-----------------------------------------------------------------------------
386 #-----------------------------------------------------------------------------
386
387
387 # to minimize subclass changes:
388 # to minimize subclass changes:
388 HTTPError = web.HTTPError
389 HTTPError = web.HTTPError
389
390
390 class FileFindHandler(web.StaticFileHandler):
391 class FileFindHandler(web.StaticFileHandler):
391 """subclass of StaticFileHandler for serving files from a search path"""
392 """subclass of StaticFileHandler for serving files from a search path"""
392
393
393 # cache search results, don't search for files more than once
394 # cache search results, don't search for files more than once
394 _static_paths = {}
395 _static_paths = {}
395
396
396 def set_headers(self):
397 def set_headers(self):
397 super(FileFindHandler, self).set_headers()
398 super(FileFindHandler, self).set_headers()
398 # disable browser caching, rely on 304 replies for savings
399 # disable browser caching, rely on 304 replies for savings
399 if "v" not in self.request.arguments or \
400 if "v" not in self.request.arguments or \
400 any(self.request.path.startswith(path) for path in self.no_cache_paths):
401 any(self.request.path.startswith(path) for path in self.no_cache_paths):
401 self.add_header("Cache-Control", "no-cache")
402 self.add_header("Cache-Control", "no-cache")
402
403
403 def initialize(self, path, default_filename=None, no_cache_paths=None):
404 def initialize(self, path, default_filename=None, no_cache_paths=None):
404 self.no_cache_paths = no_cache_paths or []
405 self.no_cache_paths = no_cache_paths or []
405
406
406 if isinstance(path, string_types):
407 if isinstance(path, string_types):
407 path = [path]
408 path = [path]
408
409
409 self.root = tuple(
410 self.root = tuple(
410 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
411 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
411 )
412 )
412 self.default_filename = default_filename
413 self.default_filename = default_filename
413
414
414 def compute_etag(self):
415 def compute_etag(self):
415 return None
416 return None
416
417
417 @classmethod
418 @classmethod
418 def get_absolute_path(cls, roots, path):
419 def get_absolute_path(cls, roots, path):
419 """locate a file to serve on our static file search path"""
420 """locate a file to serve on our static file search path"""
420 with cls._lock:
421 with cls._lock:
421 if path in cls._static_paths:
422 if path in cls._static_paths:
422 return cls._static_paths[path]
423 return cls._static_paths[path]
423 try:
424 try:
424 abspath = os.path.abspath(filefind(path, roots))
425 abspath = os.path.abspath(filefind(path, roots))
425 except IOError:
426 except IOError:
426 # IOError means not found
427 # IOError means not found
427 return ''
428 return ''
428
429
429 cls._static_paths[path] = abspath
430 cls._static_paths[path] = abspath
430 return abspath
431 return abspath
431
432
432 def validate_absolute_path(self, root, absolute_path):
433 def validate_absolute_path(self, root, absolute_path):
433 """check if the file should be served (raises 404, 403, etc.)"""
434 """check if the file should be served (raises 404, 403, etc.)"""
434 if absolute_path == '':
435 if absolute_path == '':
435 raise web.HTTPError(404)
436 raise web.HTTPError(404)
436
437
437 for root in self.root:
438 for root in self.root:
438 if (absolute_path + os.sep).startswith(root):
439 if (absolute_path + os.sep).startswith(root):
439 break
440 break
440
441
441 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
442 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
442
443
443
444
444 class ApiVersionHandler(IPythonHandler):
445 class ApiVersionHandler(IPythonHandler):
445
446
446 @json_errors
447 @json_errors
447 def get(self):
448 def get(self):
448 # not authenticated, so give as few info as possible
449 # not authenticated, so give as few info as possible
449 self.finish(json.dumps({"version":IPython.__version__}))
450 self.finish(json.dumps({"version":IPython.__version__}))
450
451
451
452
452 class TrailingSlashHandler(web.RequestHandler):
453 class TrailingSlashHandler(web.RequestHandler):
453 """Simple redirect handler that strips trailing slashes
454 """Simple redirect handler that strips trailing slashes
454
455
455 This should be the first, highest priority handler.
456 This should be the first, highest priority handler.
456 """
457 """
457
458
458 def get(self):
459 def get(self):
459 self.redirect(self.request.uri.rstrip('/'))
460 self.redirect(self.request.uri.rstrip('/'))
460
461
461 post = put = get
462 post = put = get
462
463
463
464
464 class FilesRedirectHandler(IPythonHandler):
465 class FilesRedirectHandler(IPythonHandler):
465 """Handler for redirecting relative URLs to the /files/ handler"""
466 """Handler for redirecting relative URLs to the /files/ handler"""
466 def get(self, path=''):
467 def get(self, path=''):
467 cm = self.contents_manager
468 cm = self.contents_manager
468 if cm.dir_exists(path):
469 if cm.dir_exists(path):
469 # it's a *directory*, redirect to /tree
470 # it's a *directory*, redirect to /tree
470 url = url_path_join(self.base_url, 'tree', path)
471 url = url_path_join(self.base_url, 'tree', path)
471 else:
472 else:
472 orig_path = path
473 orig_path = path
473 # otherwise, redirect to /files
474 # otherwise, redirect to /files
474 parts = path.split('/')
475 parts = path.split('/')
475
476
476 if not cm.file_exists(path=path) and 'files' in parts:
477 if not cm.file_exists(path=path) and 'files' in parts:
477 # redirect without files/ iff it would 404
478 # redirect without files/ iff it would 404
478 # this preserves pre-2.0-style 'files/' links
479 # this preserves pre-2.0-style 'files/' links
479 self.log.warn("Deprecated files/ URL: %s", orig_path)
480 self.log.warn("Deprecated files/ URL: %s", orig_path)
480 parts.remove('files')
481 parts.remove('files')
481 path = '/'.join(parts)
482 path = '/'.join(parts)
482
483
483 if not cm.file_exists(path=path):
484 if not cm.file_exists(path=path):
484 raise web.HTTPError(404)
485 raise web.HTTPError(404)
485
486
486 url = url_path_join(self.base_url, 'files', path)
487 url = url_path_join(self.base_url, 'files', path)
487 url = url_escape(url)
488 url = url_escape(url)
488 self.log.debug("Redirecting %s to %s", self.request.path, url)
489 self.log.debug("Redirecting %s to %s", self.request.path, url)
489 self.redirect(url)
490 self.redirect(url)
490
491
491
492
492 #-----------------------------------------------------------------------------
493 #-----------------------------------------------------------------------------
493 # URL pattern fragments for re-use
494 # URL pattern fragments for re-use
494 #-----------------------------------------------------------------------------
495 #-----------------------------------------------------------------------------
495
496
496 # path matches any number of `/foo[/bar...]` or just `/` or ''
497 # path matches any number of `/foo[/bar...]` or just `/` or ''
497 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
498 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
498 notebook_path_regex = r"(?P<path>(?:/[^/]+)+\.ipynb)"
499 notebook_path_regex = r"(?P<path>(?:/[^/]+)+\.ipynb)"
499
500
500 #-----------------------------------------------------------------------------
501 #-----------------------------------------------------------------------------
501 # URL to handler mappings
502 # URL to handler mappings
502 #-----------------------------------------------------------------------------
503 #-----------------------------------------------------------------------------
503
504
504
505
505 default_handlers = [
506 default_handlers = [
506 (r".*/", TrailingSlashHandler),
507 (r".*/", TrailingSlashHandler),
507 (r"api", ApiVersionHandler)
508 (r"api", ApiVersionHandler)
508 ]
509 ]
@@ -1,322 +1,336
1 """Tornado handlers for the contents web service."""
1 """Tornado handlers for the contents web service."""
2
2
3 # Copyright (c) IPython Development Team.
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
4 # Distributed under the terms of the Modified BSD License.
5
5
6 import json
6 import json
7
7
8 from tornado import web
8 from tornado import gen, web
9
9
10 from IPython.html.utils import url_path_join, url_escape
10 from IPython.html.utils import url_path_join, url_escape
11 from IPython.utils.jsonutil import date_default
11 from IPython.utils.jsonutil import date_default
12
12
13 from IPython.html.base.handlers import (
13 from IPython.html.base.handlers import (
14 IPythonHandler, json_errors, path_regex,
14 IPythonHandler, json_errors, path_regex,
15 )
15 )
16
16
17
17
18 def sort_key(model):
18 def sort_key(model):
19 """key function for case-insensitive sort by name and type"""
19 """key function for case-insensitive sort by name and type"""
20 iname = model['name'].lower()
20 iname = model['name'].lower()
21 type_key = {
21 type_key = {
22 'directory' : '0',
22 'directory' : '0',
23 'notebook' : '1',
23 'notebook' : '1',
24 'file' : '2',
24 'file' : '2',
25 }.get(model['type'], '9')
25 }.get(model['type'], '9')
26 return u'%s%s' % (type_key, iname)
26 return u'%s%s' % (type_key, iname)
27
27
28
28
29 def validate_model(model, expect_content):
29 def validate_model(model, expect_content):
30 """
30 """
31 Validate a model returned by a ContentsManager method.
31 Validate a model returned by a ContentsManager method.
32
32
33 If expect_content is True, then we expect non-null entries for 'content'
33 If expect_content is True, then we expect non-null entries for 'content'
34 and 'format'.
34 and 'format'.
35 """
35 """
36 required_keys = {
36 required_keys = {
37 "name",
37 "name",
38 "path",
38 "path",
39 "type",
39 "type",
40 "writable",
40 "writable",
41 "created",
41 "created",
42 "last_modified",
42 "last_modified",
43 "mimetype",
43 "mimetype",
44 "content",
44 "content",
45 "format",
45 "format",
46 }
46 }
47 missing = required_keys - set(model.keys())
47 missing = required_keys - set(model.keys())
48 if missing:
48 if missing:
49 raise web.HTTPError(
49 raise web.HTTPError(
50 500,
50 500,
51 u"Missing Model Keys: {missing}".format(missing=missing),
51 u"Missing Model Keys: {missing}".format(missing=missing),
52 )
52 )
53
53
54 maybe_none_keys = ['content', 'format']
54 maybe_none_keys = ['content', 'format']
55 if model['type'] == 'file':
55 if model['type'] == 'file':
56 # mimetype should be populated only for file models
56 # mimetype should be populated only for file models
57 maybe_none_keys.append('mimetype')
57 maybe_none_keys.append('mimetype')
58 if expect_content:
58 if expect_content:
59 errors = [key for key in maybe_none_keys if model[key] is None]
59 errors = [key for key in maybe_none_keys if model[key] is None]
60 if errors:
60 if errors:
61 raise web.HTTPError(
61 raise web.HTTPError(
62 500,
62 500,
63 u"Keys unexpectedly None: {keys}".format(keys=errors),
63 u"Keys unexpectedly None: {keys}".format(keys=errors),
64 )
64 )
65 else:
65 else:
66 errors = {
66 errors = {
67 key: model[key]
67 key: model[key]
68 for key in maybe_none_keys
68 for key in maybe_none_keys
69 if model[key] is not None
69 if model[key] is not None
70 }
70 }
71 if errors:
71 if errors:
72 raise web.HTTPError(
72 raise web.HTTPError(
73 500,
73 500,
74 u"Keys unexpectedly not None: {keys}".format(keys=errors),
74 u"Keys unexpectedly not None: {keys}".format(keys=errors),
75 )
75 )
76
76
77
77
78 class ContentsHandler(IPythonHandler):
78 class ContentsHandler(IPythonHandler):
79
79
80 SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE')
80 SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE')
81
81
82 def location_url(self, path):
82 def location_url(self, path):
83 """Return the full URL location of a file.
83 """Return the full URL location of a file.
84
84
85 Parameters
85 Parameters
86 ----------
86 ----------
87 path : unicode
87 path : unicode
88 The API path of the file, such as "foo/bar.txt".
88 The API path of the file, such as "foo/bar.txt".
89 """
89 """
90 return url_escape(url_path_join(
90 return url_escape(url_path_join(
91 self.base_url, 'api', 'contents', path
91 self.base_url, 'api', 'contents', path
92 ))
92 ))
93
93
94 def _finish_model(self, model, location=True):
94 def _finish_model(self, model, location=True):
95 """Finish a JSON request with a model, setting relevant headers, etc."""
95 """Finish a JSON request with a model, setting relevant headers, etc."""
96 if location:
96 if location:
97 location = self.location_url(model['path'])
97 location = self.location_url(model['path'])
98 self.set_header('Location', location)
98 self.set_header('Location', location)
99 self.set_header('Last-Modified', model['last_modified'])
99 self.set_header('Last-Modified', model['last_modified'])
100 self.set_header('Content-Type', 'application/json')
100 self.set_header('Content-Type', 'application/json')
101 self.finish(json.dumps(model, default=date_default))
101 self.finish(json.dumps(model, default=date_default))
102
102
103 @web.authenticated
103 @web.authenticated
104 @json_errors
104 @json_errors
105 @gen.coroutine
105 def get(self, path=''):
106 def get(self, path=''):
106 """Return a model for a file or directory.
107 """Return a model for a file or directory.
107
108
108 A directory model contains a list of models (without content)
109 A directory model contains a list of models (without content)
109 of the files and directories it contains.
110 of the files and directories it contains.
110 """
111 """
111 path = path or ''
112 path = path or ''
112 type = self.get_query_argument('type', default=None)
113 type = self.get_query_argument('type', default=None)
113 if type not in {None, 'directory', 'file', 'notebook'}:
114 if type not in {None, 'directory', 'file', 'notebook'}:
114 raise web.HTTPError(400, u'Type %r is invalid' % type)
115 raise web.HTTPError(400, u'Type %r is invalid' % type)
115
116
116 format = self.get_query_argument('format', default=None)
117 format = self.get_query_argument('format', default=None)
117 if format not in {None, 'text', 'base64'}:
118 if format not in {None, 'text', 'base64'}:
118 raise web.HTTPError(400, u'Format %r is invalid' % format)
119 raise web.HTTPError(400, u'Format %r is invalid' % format)
119
120
120 model = self.contents_manager.get(path=path, type=type, format=format)
121 model = yield gen.maybe_future(self.contents_manager.get(path=path, type=type, format=format))
121 if model['type'] == 'directory':
122 if model['type'] == 'directory':
122 # group listing by type, then by name (case-insensitive)
123 # group listing by type, then by name (case-insensitive)
123 # FIXME: sorting should be done in the frontends
124 # FIXME: sorting should be done in the frontends
124 model['content'].sort(key=sort_key)
125 model['content'].sort(key=sort_key)
125 validate_model(model, expect_content=True)
126 validate_model(model, expect_content=True)
126 self._finish_model(model, location=False)
127 self._finish_model(model, location=False)
127
128
128 @web.authenticated
129 @web.authenticated
129 @json_errors
130 @json_errors
131 @gen.coroutine
130 def patch(self, path=''):
132 def patch(self, path=''):
131 """PATCH renames a file or directory without re-uploading content."""
133 """PATCH renames a file or directory without re-uploading content."""
132 cm = self.contents_manager
134 cm = self.contents_manager
133 model = self.get_json_body()
135 model = self.get_json_body()
134 if model is None:
136 if model is None:
135 raise web.HTTPError(400, u'JSON body missing')
137 raise web.HTTPError(400, u'JSON body missing')
136 model = cm.update(model, path)
138 model = yield gen.maybe_future(cm.update(model, path))
137 validate_model(model, expect_content=False)
139 validate_model(model, expect_content=False)
138 self._finish_model(model)
140 self._finish_model(model)
139
141
142 @gen.coroutine
140 def _copy(self, copy_from, copy_to=None):
143 def _copy(self, copy_from, copy_to=None):
141 """Copy a file, optionally specifying a target directory."""
144 """Copy a file, optionally specifying a target directory."""
142 self.log.info(u"Copying {copy_from} to {copy_to}".format(
145 self.log.info(u"Copying {copy_from} to {copy_to}".format(
143 copy_from=copy_from,
146 copy_from=copy_from,
144 copy_to=copy_to or '',
147 copy_to=copy_to or '',
145 ))
148 ))
146 model = self.contents_manager.copy(copy_from, copy_to)
149 model = yield gen.maybe_future(self.contents_manager.copy(copy_from, copy_to))
147 self.set_status(201)
150 self.set_status(201)
148 validate_model(model, expect_content=False)
151 validate_model(model, expect_content=False)
149 self._finish_model(model)
152 self._finish_model(model)
150
153
154 @gen.coroutine
151 def _upload(self, model, path):
155 def _upload(self, model, path):
152 """Handle upload of a new file to path"""
156 """Handle upload of a new file to path"""
153 self.log.info(u"Uploading file to %s", path)
157 self.log.info(u"Uploading file to %s", path)
154 model = self.contents_manager.new(model, path)
158 model = yield gen.maybe_future(self.contents_manager.new(model, path))
155 self.set_status(201)
159 self.set_status(201)
156 validate_model(model, expect_content=False)
160 validate_model(model, expect_content=False)
157 self._finish_model(model)
161 self._finish_model(model)
158
162
163 @gen.coroutine
159 def _new_untitled(self, path, type='', ext=''):
164 def _new_untitled(self, path, type='', ext=''):
160 """Create a new, empty untitled entity"""
165 """Create a new, empty untitled entity"""
161 self.log.info(u"Creating new %s in %s", type or 'file', path)
166 self.log.info(u"Creating new %s in %s", type or 'file', path)
162 model = self.contents_manager.new_untitled(path=path, type=type, ext=ext)
167 model = yield gen.maybe_future(self.contents_manager.new_untitled(path=path, type=type, ext=ext))
163 self.set_status(201)
168 self.set_status(201)
164 validate_model(model, expect_content=False)
169 validate_model(model, expect_content=False)
165 self._finish_model(model)
170 self._finish_model(model)
166
171
172 @gen.coroutine
167 def _save(self, model, path):
173 def _save(self, model, path):
168 """Save an existing file."""
174 """Save an existing file."""
169 self.log.info(u"Saving file at %s", path)
175 self.log.info(u"Saving file at %s", path)
170 model = self.contents_manager.save(model, path)
176 model = yield gen.maybe_future(self.contents_manager.save(model, path))
171 validate_model(model, expect_content=False)
177 validate_model(model, expect_content=False)
172 self._finish_model(model)
178 self._finish_model(model)
173
179
174 @web.authenticated
180 @web.authenticated
175 @json_errors
181 @json_errors
182 @gen.coroutine
176 def post(self, path=''):
183 def post(self, path=''):
177 """Create a new file in the specified path.
184 """Create a new file in the specified path.
178
185
179 POST creates new files. The server always decides on the name.
186 POST creates new files. The server always decides on the name.
180
187
181 POST /api/contents/path
188 POST /api/contents/path
182 New untitled, empty file or directory.
189 New untitled, empty file or directory.
183 POST /api/contents/path
190 POST /api/contents/path
184 with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
191 with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
185 New copy of OtherNotebook in path
192 New copy of OtherNotebook in path
186 """
193 """
187
194
188 cm = self.contents_manager
195 cm = self.contents_manager
189
196
190 if cm.file_exists(path):
197 if cm.file_exists(path):
191 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
198 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
192
199
193 if not cm.dir_exists(path):
200 if not cm.dir_exists(path):
194 raise web.HTTPError(404, "No such directory: %s" % path)
201 raise web.HTTPError(404, "No such directory: %s" % path)
195
202
196 model = self.get_json_body()
203 model = self.get_json_body()
197
204
198 if model is not None:
205 if model is not None:
199 copy_from = model.get('copy_from')
206 copy_from = model.get('copy_from')
200 ext = model.get('ext', '')
207 ext = model.get('ext', '')
201 type = model.get('type', '')
208 type = model.get('type', '')
202 if copy_from:
209 if copy_from:
203 self._copy(copy_from, path)
210 yield self._copy(copy_from, path)
204 else:
211 else:
205 self._new_untitled(path, type=type, ext=ext)
212 yield self._new_untitled(path, type=type, ext=ext)
206 else:
213 else:
207 self._new_untitled(path)
214 yield self._new_untitled(path)
208
215
209 @web.authenticated
216 @web.authenticated
210 @json_errors
217 @json_errors
218 @gen.coroutine
211 def put(self, path=''):
219 def put(self, path=''):
212 """Saves the file in the location specified by name and path.
220 """Saves the file in the location specified by name and path.
213
221
214 PUT is very similar to POST, but the requester specifies the name,
222 PUT is very similar to POST, but the requester specifies the name,
215 whereas with POST, the server picks the name.
223 whereas with POST, the server picks the name.
216
224
217 PUT /api/contents/path/Name.ipynb
225 PUT /api/contents/path/Name.ipynb
218 Save notebook at ``path/Name.ipynb``. Notebook structure is specified
226 Save notebook at ``path/Name.ipynb``. Notebook structure is specified
219 in `content` key of JSON request body. If content is not specified,
227 in `content` key of JSON request body. If content is not specified,
220 create a new empty notebook.
228 create a new empty notebook.
221 """
229 """
222 model = self.get_json_body()
230 model = self.get_json_body()
223 if model:
231 if model:
224 if model.get('copy_from'):
232 if model.get('copy_from'):
225 raise web.HTTPError(400, "Cannot copy with PUT, only POST")
233 raise web.HTTPError(400, "Cannot copy with PUT, only POST")
226 if self.contents_manager.file_exists(path):
234 exists = yield gen.maybe_future(self.contents_manager.file_exists(path))
227 self._save(model, path)
235 if exists:
236 yield gen.maybe_future(self._save(model, path))
228 else:
237 else:
229 self._upload(model, path)
238 yield gen.maybe_future(self._upload(model, path))
230 else:
239 else:
231 self._new_untitled(path)
240 yield gen.maybe_future(self._new_untitled(path))
232
241
233 @web.authenticated
242 @web.authenticated
234 @json_errors
243 @json_errors
244 @gen.coroutine
235 def delete(self, path=''):
245 def delete(self, path=''):
236 """delete a file in the given path"""
246 """delete a file in the given path"""
237 cm = self.contents_manager
247 cm = self.contents_manager
238 self.log.warn('delete %s', path)
248 self.log.warn('delete %s', path)
239 cm.delete(path)
249 yield gen.maybe_future(cm.delete(path))
240 self.set_status(204)
250 self.set_status(204)
241 self.finish()
251 self.finish()
242
252
243
253
244 class CheckpointsHandler(IPythonHandler):
254 class CheckpointsHandler(IPythonHandler):
245
255
246 SUPPORTED_METHODS = ('GET', 'POST')
256 SUPPORTED_METHODS = ('GET', 'POST')
247
257
248 @web.authenticated
258 @web.authenticated
249 @json_errors
259 @json_errors
260 @gen.coroutine
250 def get(self, path=''):
261 def get(self, path=''):
251 """get lists checkpoints for a file"""
262 """get lists checkpoints for a file"""
252 cm = self.contents_manager
263 cm = self.contents_manager
253 checkpoints = cm.list_checkpoints(path)
264 checkpoints = yield gen.maybe_future(cm.list_checkpoints(path))
254 data = json.dumps(checkpoints, default=date_default)
265 data = json.dumps(checkpoints, default=date_default)
255 self.finish(data)
266 self.finish(data)
256
267
257 @web.authenticated
268 @web.authenticated
258 @json_errors
269 @json_errors
270 @gen.coroutine
259 def post(self, path=''):
271 def post(self, path=''):
260 """post creates a new checkpoint"""
272 """post creates a new checkpoint"""
261 cm = self.contents_manager
273 cm = self.contents_manager
262 checkpoint = cm.create_checkpoint(path)
274 checkpoint = yield gen.maybe_future(cm.create_checkpoint(path))
263 data = json.dumps(checkpoint, default=date_default)
275 data = json.dumps(checkpoint, default=date_default)
264 location = url_path_join(self.base_url, 'api/contents',
276 location = url_path_join(self.base_url, 'api/contents',
265 path, 'checkpoints', checkpoint['id'])
277 path, 'checkpoints', checkpoint['id'])
266 self.set_header('Location', url_escape(location))
278 self.set_header('Location', url_escape(location))
267 self.set_status(201)
279 self.set_status(201)
268 self.finish(data)
280 self.finish(data)
269
281
270
282
271 class ModifyCheckpointsHandler(IPythonHandler):
283 class ModifyCheckpointsHandler(IPythonHandler):
272
284
273 SUPPORTED_METHODS = ('POST', 'DELETE')
285 SUPPORTED_METHODS = ('POST', 'DELETE')
274
286
275 @web.authenticated
287 @web.authenticated
276 @json_errors
288 @json_errors
289 @gen.coroutine
277 def post(self, path, checkpoint_id):
290 def post(self, path, checkpoint_id):
278 """post restores a file from a checkpoint"""
291 """post restores a file from a checkpoint"""
279 cm = self.contents_manager
292 cm = self.contents_manager
280 cm.restore_checkpoint(checkpoint_id, path)
293 yield gen.maybe_future(cm.restore_checkpoint(checkpoint_id, path))
281 self.set_status(204)
294 self.set_status(204)
282 self.finish()
295 self.finish()
283
296
284 @web.authenticated
297 @web.authenticated
285 @json_errors
298 @json_errors
299 @gen.coroutine
286 def delete(self, path, checkpoint_id):
300 def delete(self, path, checkpoint_id):
287 """delete clears a checkpoint for a given file"""
301 """delete clears a checkpoint for a given file"""
288 cm = self.contents_manager
302 cm = self.contents_manager
289 cm.delete_checkpoint(checkpoint_id, path)
303 yield gen.maybe_future(cm.delete_checkpoint(checkpoint_id, path))
290 self.set_status(204)
304 self.set_status(204)
291 self.finish()
305 self.finish()
292
306
293
307
294 class NotebooksRedirectHandler(IPythonHandler):
308 class NotebooksRedirectHandler(IPythonHandler):
295 """Redirect /api/notebooks to /api/contents"""
309 """Redirect /api/notebooks to /api/contents"""
296 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
310 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
297
311
298 def get(self, path):
312 def get(self, path):
299 self.log.warn("/api/notebooks is deprecated, use /api/contents")
313 self.log.warn("/api/notebooks is deprecated, use /api/contents")
300 self.redirect(url_path_join(
314 self.redirect(url_path_join(
301 self.base_url,
315 self.base_url,
302 'api/contents',
316 'api/contents',
303 path
317 path
304 ))
318 ))
305
319
306 put = patch = post = delete = get
320 put = patch = post = delete = get
307
321
308
322
309 #-----------------------------------------------------------------------------
323 #-----------------------------------------------------------------------------
310 # URL to handler mappings
324 # URL to handler mappings
311 #-----------------------------------------------------------------------------
325 #-----------------------------------------------------------------------------
312
326
313
327
314 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
328 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
315
329
316 default_handlers = [
330 default_handlers = [
317 (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
331 (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
318 (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
332 (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
319 ModifyCheckpointsHandler),
333 ModifyCheckpointsHandler),
320 (r"/api/contents%s" % path_regex, ContentsHandler),
334 (r"/api/contents%s" % path_regex, ContentsHandler),
321 (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
335 (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
322 ]
336 ]
General Comments 0
You need to be logged in to leave comments. Login now