##// END OF EJS Templates
Merge branch csp into 3.x...
Min RK -
r21487:7222bd53 merge
parent child Browse files
Show More
@@ -1,528 +1,553 b''
1 1 """Base Tornado handlers for the notebook server."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import functools
7 7 import json
8 8 import logging
9 9 import os
10 10 import re
11 11 import sys
12 12 import traceback
13 13 try:
14 14 # py3
15 15 from http.client import responses
16 16 except ImportError:
17 17 from httplib import responses
18 18
19 19 from jinja2 import TemplateNotFound
20 20 from tornado import web
21 21
22 22 from tornado import gen
23 23 from tornado.log import app_log
24 24
25 25
26 26 import IPython
27 27 from IPython.utils.sysinfo import get_sys_info
28 28
29 29 from IPython.config import Application
30 30 from IPython.utils.path import filefind
31 31 from IPython.utils.py3compat import string_types
32 32 from IPython.html.utils import is_hidden, url_path_join, url_escape
33 33
34 34 from IPython.html.services.security import csp_report_uri
35 35
36 36 #-----------------------------------------------------------------------------
37 37 # Top-level handlers
38 38 #-----------------------------------------------------------------------------
39 39 non_alphanum = re.compile(r'[^A-Za-z0-9]')
40 40
41 41 sys_info = json.dumps(get_sys_info())
42 42
43 43 class AuthenticatedHandler(web.RequestHandler):
44 44 """A RequestHandler with an authenticated user."""
45
46 @property
47 def content_security_policy(self):
48 """The default Content-Security-Policy header
49
50 Can be overridden by defining Content-Security-Policy in settings['headers']
51 """
52 return '; '.join([
53 "frame-ancestors 'self'",
54 # Make sure the report-uri is relative to the base_url
55 "report-uri " + url_path_join(self.base_url, csp_report_uri),
56 ])
45 57
46 58 def set_default_headers(self):
47 59 headers = self.settings.get('headers', {})
48 60
49 61 if "Content-Security-Policy" not in headers:
50 headers["Content-Security-Policy"] = (
51 "frame-ancestors 'self'; "
52 # Make sure the report-uri is relative to the base_url
53 "report-uri " + url_path_join(self.base_url, csp_report_uri) + ";"
54 )
62 headers["Content-Security-Policy"] = self.content_security_policy
55 63
56 64 # Allow for overriding headers
57 65 for header_name,value in headers.items() :
58 66 try:
59 67 self.set_header(header_name, value)
60 68 except Exception as e:
61 69 # tornado raise Exception (not a subclass)
62 70 # if method is unsupported (websocket and Access-Control-Allow-Origin
63 71 # for example, so just ignore)
64 72 self.log.debug(e)
65 73
66 74 def clear_login_cookie(self):
67 75 self.clear_cookie(self.cookie_name)
68 76
69 77 def get_current_user(self):
70 78 if self.login_handler is None:
71 79 return 'anonymous'
72 80 return self.login_handler.get_user(self)
73 81
74 82 @property
75 83 def cookie_name(self):
76 84 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
77 85 self.request.host
78 86 ))
79 87 return self.settings.get('cookie_name', default_cookie_name)
80 88
81 89 @property
82 90 def logged_in(self):
83 91 """Is a user currently logged in?"""
84 92 user = self.get_current_user()
85 93 return (user and not user == 'anonymous')
86 94
87 95 @property
88 96 def login_handler(self):
89 97 """Return the login handler for this application, if any."""
90 98 return self.settings.get('login_handler_class', None)
91 99
92 100 @property
93 101 def login_available(self):
94 102 """May a user proceed to log in?
95 103
96 104 This returns True if login capability is available, irrespective of
97 105 whether the user is already logged in or not.
98 106
99 107 """
100 108 if self.login_handler is None:
101 109 return False
102 110 return bool(self.login_handler.login_available(self.settings))
103 111
104 112
105 113 class IPythonHandler(AuthenticatedHandler):
106 114 """IPython-specific extensions to authenticated handling
107 115
108 116 Mostly property shortcuts to IPython-specific settings.
109 117 """
110 118
111 119 @property
112 120 def config(self):
113 121 return self.settings.get('config', None)
114 122
115 123 @property
116 124 def log(self):
117 125 """use the IPython log by default, falling back on tornado's logger"""
118 126 if Application.initialized():
119 127 return Application.instance().log
120 128 else:
121 129 return app_log
122 130
123 131 @property
124 132 def jinja_template_vars(self):
125 133 """User-supplied values to supply to jinja templates."""
126 134 return self.settings.get('jinja_template_vars', {})
127 135
128 136 #---------------------------------------------------------------
129 137 # URLs
130 138 #---------------------------------------------------------------
131 139
132 140 @property
133 141 def version_hash(self):
134 142 """The version hash to use for cache hints for static files"""
135 143 return self.settings.get('version_hash', '')
136 144
137 145 @property
138 146 def mathjax_url(self):
139 147 return self.settings.get('mathjax_url', '')
140 148
141 149 @property
142 150 def base_url(self):
143 151 return self.settings.get('base_url', '/')
144 152
145 153 @property
146 154 def default_url(self):
147 155 return self.settings.get('default_url', '')
148 156
149 157 @property
150 158 def ws_url(self):
151 159 return self.settings.get('websocket_url', '')
152 160
153 161 @property
154 162 def contents_js_source(self):
155 163 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
156 164 'services/contents'))
157 165 return self.settings.get('contents_js_source', 'services/contents')
158 166
159 167 #---------------------------------------------------------------
160 168 # Manager objects
161 169 #---------------------------------------------------------------
162 170
163 171 @property
164 172 def kernel_manager(self):
165 173 return self.settings['kernel_manager']
166 174
167 175 @property
168 176 def contents_manager(self):
169 177 return self.settings['contents_manager']
170 178
171 179 @property
172 180 def cluster_manager(self):
173 181 return self.settings['cluster_manager']
174 182
175 183 @property
176 184 def session_manager(self):
177 185 return self.settings['session_manager']
178 186
179 187 @property
180 188 def terminal_manager(self):
181 189 return self.settings['terminal_manager']
182 190
183 191 @property
184 192 def kernel_spec_manager(self):
185 193 return self.settings['kernel_spec_manager']
186 194
187 195 @property
188 196 def config_manager(self):
189 197 return self.settings['config_manager']
190 198
191 199 #---------------------------------------------------------------
192 200 # CORS
193 201 #---------------------------------------------------------------
194 202
195 203 @property
196 204 def allow_origin(self):
197 205 """Normal Access-Control-Allow-Origin"""
198 206 return self.settings.get('allow_origin', '')
199 207
200 208 @property
201 209 def allow_origin_pat(self):
202 210 """Regular expression version of allow_origin"""
203 211 return self.settings.get('allow_origin_pat', None)
204 212
205 213 @property
206 214 def allow_credentials(self):
207 215 """Whether to set Access-Control-Allow-Credentials"""
208 216 return self.settings.get('allow_credentials', False)
209 217
210 218 def set_default_headers(self):
211 219 """Add CORS headers, if defined"""
212 220 super(IPythonHandler, self).set_default_headers()
213 221 if self.allow_origin:
214 222 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
215 223 elif self.allow_origin_pat:
216 224 origin = self.get_origin()
217 225 if origin and self.allow_origin_pat.match(origin):
218 226 self.set_header("Access-Control-Allow-Origin", origin)
219 227 if self.allow_credentials:
220 228 self.set_header("Access-Control-Allow-Credentials", 'true')
221 229
222 230 def get_origin(self):
223 231 # Handle WebSocket Origin naming convention differences
224 232 # The difference between version 8 and 13 is that in 8 the
225 233 # client sends a "Sec-Websocket-Origin" header and in 13 it's
226 234 # simply "Origin".
227 235 if "Origin" in self.request.headers:
228 236 origin = self.request.headers.get("Origin")
229 237 else:
230 238 origin = self.request.headers.get("Sec-Websocket-Origin", None)
231 239 return origin
232 240
233 241 #---------------------------------------------------------------
234 242 # template rendering
235 243 #---------------------------------------------------------------
236 244
237 245 def get_template(self, name):
238 246 """Return the jinja template object for a given name"""
239 247 return self.settings['jinja2_env'].get_template(name)
240 248
241 249 def render_template(self, name, **ns):
242 250 ns.update(self.template_namespace)
243 251 template = self.get_template(name)
244 252 return template.render(**ns)
245 253
246 254 @property
247 255 def template_namespace(self):
248 256 return dict(
249 257 base_url=self.base_url,
250 258 default_url=self.default_url,
251 259 ws_url=self.ws_url,
252 260 logged_in=self.logged_in,
253 261 login_available=self.login_available,
254 262 static_url=self.static_url,
255 263 sys_info=sys_info,
256 264 contents_js_source=self.contents_js_source,
257 265 version_hash=self.version_hash,
258 266 **self.jinja_template_vars
259 267 )
260 268
261 269 def get_json_body(self):
262 270 """Return the body of the request as JSON data."""
263 271 if not self.request.body:
264 272 return None
265 273 # Do we need to call body.decode('utf-8') here?
266 274 body = self.request.body.strip().decode(u'utf-8')
267 275 try:
268 276 model = json.loads(body)
269 277 except Exception:
270 278 self.log.debug("Bad JSON: %r", body)
271 279 self.log.error("Couldn't parse JSON", exc_info=True)
272 280 raise web.HTTPError(400, u'Invalid JSON in body of request')
273 281 return model
274 282
275 283 def write_error(self, status_code, **kwargs):
276 284 """render custom error pages"""
277 285 exc_info = kwargs.get('exc_info')
278 286 message = ''
279 287 status_message = responses.get(status_code, 'Unknown HTTP Error')
280 288 if exc_info:
281 289 exception = exc_info[1]
282 290 # get the custom message, if defined
283 291 try:
284 292 message = exception.log_message % exception.args
285 293 except Exception:
286 294 pass
287 295
288 296 # construct the custom reason, if defined
289 297 reason = getattr(exception, 'reason', '')
290 298 if reason:
291 299 status_message = reason
292 300
293 301 # build template namespace
294 302 ns = dict(
295 303 status_code=status_code,
296 304 status_message=status_message,
297 305 message=message,
298 306 exception=exception,
299 307 )
300 308
301 309 self.set_header('Content-Type', 'text/html')
302 310 # render the template
303 311 try:
304 312 html = self.render_template('%s.html' % status_code, **ns)
305 313 except TemplateNotFound:
306 314 self.log.debug("No template for %d", status_code)
307 315 html = self.render_template('error.html', **ns)
308 316
309 317 self.write(html)
310
318
319
320 class APIHandler(IPythonHandler):
321 """Base class for API handlers"""
322
323 @property
324 def content_security_policy(self):
325 csp = '; '.join([
326 super(APIHandler, self).content_security_policy,
327 "default-src 'none'",
328 ])
329 return csp
330
331 def finish(self, *args, **kwargs):
332 self.set_header('Content-Type', 'application/json')
333 return super(APIHandler, self).finish(*args, **kwargs)
311 334
312 335
313 336 class Template404(IPythonHandler):
314 337 """Render our 404 template"""
315 338 def prepare(self):
316 339 raise web.HTTPError(404)
317 340
318 341
319 342 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
320 343 """static files should only be accessible when logged in"""
321 344
322 345 @web.authenticated
323 346 def get(self, path):
324 347 if os.path.splitext(path)[1] == '.ipynb':
325 348 name = path.rsplit('/', 1)[-1]
326 349 self.set_header('Content-Type', 'application/json')
327 350 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
328 351
329 352 return web.StaticFileHandler.get(self, path)
330 353
331 354 def set_headers(self):
332 355 super(AuthenticatedFileHandler, self).set_headers()
333 356 # disable browser caching, rely on 304 replies for savings
334 357 if "v" not in self.request.arguments:
335 358 self.add_header("Cache-Control", "no-cache")
336 359
337 360 def compute_etag(self):
338 361 return None
339 362
340 363 def validate_absolute_path(self, root, absolute_path):
341 364 """Validate and return the absolute path.
342 365
343 366 Requires tornado 3.1
344 367
345 368 Adding to tornado's own handling, forbids the serving of hidden files.
346 369 """
347 370 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
348 371 abs_root = os.path.abspath(root)
349 372 if is_hidden(abs_path, abs_root):
350 373 self.log.info("Refusing to serve hidden file, via 404 Error")
351 374 raise web.HTTPError(404)
352 375 return abs_path
353 376
354 377
355 378 def json_errors(method):
356 379 """Decorate methods with this to return GitHub style JSON errors.
357 380
358 381 This should be used on any JSON API on any handler method that can raise HTTPErrors.
359 382
360 383 This will grab the latest HTTPError exception using sys.exc_info
361 384 and then:
362 385
363 386 1. Set the HTTP status code based on the HTTPError
364 387 2. Create and return a JSON body with a message field describing
365 388 the error in a human readable form.
366 389 """
367 390 @functools.wraps(method)
368 391 @gen.coroutine
369 392 def wrapper(self, *args, **kwargs):
370 393 try:
371 394 result = yield gen.maybe_future(method(self, *args, **kwargs))
372 395 except web.HTTPError as e:
396 self.set_header('Content-Type', 'application/json')
373 397 status = e.status_code
374 398 message = e.log_message
375 399 self.log.warn(message)
376 400 self.set_status(e.status_code)
377 401 reply = dict(message=message, reason=e.reason)
378 402 self.finish(json.dumps(reply))
379 403 except Exception:
404 self.set_header('Content-Type', 'application/json')
380 405 self.log.error("Unhandled error in API request", exc_info=True)
381 406 status = 500
382 407 message = "Unknown server error"
383 408 t, value, tb = sys.exc_info()
384 409 self.set_status(status)
385 410 tb_text = ''.join(traceback.format_exception(t, value, tb))
386 411 reply = dict(message=message, reason=None, traceback=tb_text)
387 412 self.finish(json.dumps(reply))
388 413 else:
389 414 # FIXME: can use regular return in generators in py3
390 415 raise gen.Return(result)
391 416 return wrapper
392 417
393 418
394 419
395 420 #-----------------------------------------------------------------------------
396 421 # File handler
397 422 #-----------------------------------------------------------------------------
398 423
399 424 # to minimize subclass changes:
400 425 HTTPError = web.HTTPError
401 426
402 class FileFindHandler(web.StaticFileHandler):
427 class FileFindHandler(IPythonHandler, web.StaticFileHandler):
403 428 """subclass of StaticFileHandler for serving files from a search path"""
404 429
405 430 # cache search results, don't search for files more than once
406 431 _static_paths = {}
407 432
408 433 def set_headers(self):
409 434 super(FileFindHandler, self).set_headers()
410 435 # disable browser caching, rely on 304 replies for savings
411 436 if "v" not in self.request.arguments or \
412 437 any(self.request.path.startswith(path) for path in self.no_cache_paths):
413 438 self.set_header("Cache-Control", "no-cache")
414 439
415 440 def initialize(self, path, default_filename=None, no_cache_paths=None):
416 441 self.no_cache_paths = no_cache_paths or []
417 442
418 443 if isinstance(path, string_types):
419 444 path = [path]
420 445
421 446 self.root = tuple(
422 447 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
423 448 )
424 449 self.default_filename = default_filename
425 450
426 451 def compute_etag(self):
427 452 return None
428 453
429 454 @classmethod
430 455 def get_absolute_path(cls, roots, path):
431 456 """locate a file to serve on our static file search path"""
432 457 with cls._lock:
433 458 if path in cls._static_paths:
434 459 return cls._static_paths[path]
435 460 try:
436 461 abspath = os.path.abspath(filefind(path, roots))
437 462 except IOError:
438 463 # IOError means not found
439 464 return ''
440 465
441 466 cls._static_paths[path] = abspath
442 467 return abspath
443 468
444 469 def validate_absolute_path(self, root, absolute_path):
445 470 """check if the file should be served (raises 404, 403, etc.)"""
446 471 if absolute_path == '':
447 472 raise web.HTTPError(404)
448 473
449 474 for root in self.root:
450 475 if (absolute_path + os.sep).startswith(root):
451 476 break
452 477
453 478 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
454 479
455 480
456 class ApiVersionHandler(IPythonHandler):
481 class APIVersionHandler(APIHandler):
457 482
458 483 @json_errors
459 484 def get(self):
460 485 # not authenticated, so give as few info as possible
461 486 self.finish(json.dumps({"version":IPython.__version__}))
462 487
463 488
464 489 class TrailingSlashHandler(web.RequestHandler):
465 490 """Simple redirect handler that strips trailing slashes
466 491
467 492 This should be the first, highest priority handler.
468 493 """
469 494
470 495 def get(self):
471 496 self.redirect(self.request.uri.rstrip('/'))
472 497
473 498 post = put = get
474 499
475 500
476 501 class FilesRedirectHandler(IPythonHandler):
477 502 """Handler for redirecting relative URLs to the /files/ handler"""
478 503
479 504 @staticmethod
480 505 def redirect_to_files(self, path):
481 506 """make redirect logic a reusable static method
482 507
483 508 so it can be called from other handlers.
484 509 """
485 510 cm = self.contents_manager
486 511 if cm.dir_exists(path):
487 512 # it's a *directory*, redirect to /tree
488 513 url = url_path_join(self.base_url, 'tree', path)
489 514 else:
490 515 orig_path = path
491 516 # otherwise, redirect to /files
492 517 parts = path.split('/')
493 518
494 519 if not cm.file_exists(path=path) and 'files' in parts:
495 520 # redirect without files/ iff it would 404
496 521 # this preserves pre-2.0-style 'files/' links
497 522 self.log.warn("Deprecated files/ URL: %s", orig_path)
498 523 parts.remove('files')
499 524 path = '/'.join(parts)
500 525
501 526 if not cm.file_exists(path=path):
502 527 raise web.HTTPError(404)
503 528
504 529 url = url_path_join(self.base_url, 'files', path)
505 530 url = url_escape(url)
506 531 self.log.debug("Redirecting %s to %s", self.request.path, url)
507 532 self.redirect(url)
508 533
509 534 def get(self, path=''):
510 535 return self.redirect_to_files(self, path)
511 536
512 537
513 538 #-----------------------------------------------------------------------------
514 539 # URL pattern fragments for re-use
515 540 #-----------------------------------------------------------------------------
516 541
517 542 # path matches any number of `/foo[/bar...]` or just `/` or ''
518 543 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
519 544
520 545 #-----------------------------------------------------------------------------
521 546 # URL to handler mappings
522 547 #-----------------------------------------------------------------------------
523 548
524 549
525 550 default_handlers = [
526 551 (r".*/", TrailingSlashHandler),
527 (r"api", ApiVersionHandler)
552 (r"api", APIVersionHandler)
528 553 ]
@@ -1,59 +1,59 b''
1 1 """Tornado handlers for cluster web service."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import json
7 7
8 8 from tornado import web
9 9
10 from ...base.handlers import IPythonHandler
10 from ...base.handlers import APIHandler
11 11
12 12 #-----------------------------------------------------------------------------
13 13 # Cluster handlers
14 14 #-----------------------------------------------------------------------------
15 15
16 16
17 class MainClusterHandler(IPythonHandler):
17 class MainClusterHandler(APIHandler):
18 18
19 19 @web.authenticated
20 20 def get(self):
21 21 self.finish(json.dumps(self.cluster_manager.list_profiles()))
22 22
23 23
24 class ClusterProfileHandler(IPythonHandler):
24 class ClusterProfileHandler(APIHandler):
25 25
26 26 @web.authenticated
27 27 def get(self, profile):
28 28 self.finish(json.dumps(self.cluster_manager.profile_info(profile)))
29 29
30 30
31 class ClusterActionHandler(IPythonHandler):
31 class ClusterActionHandler(APIHandler):
32 32
33 33 @web.authenticated
34 34 def post(self, profile, action):
35 35 cm = self.cluster_manager
36 36 if action == 'start':
37 37 n = self.get_argument('n', default=None)
38 38 if not n:
39 39 data = cm.start_cluster(profile)
40 40 else:
41 41 data = cm.start_cluster(profile, int(n))
42 42 if action == 'stop':
43 43 data = cm.stop_cluster(profile)
44 44 self.finish(json.dumps(data))
45 45
46 46
47 47 #-----------------------------------------------------------------------------
48 48 # URL to handler mappings
49 49 #-----------------------------------------------------------------------------
50 50
51 51
52 52 _cluster_action_regex = r"(?P<action>start|stop)"
53 53 _profile_regex = r"(?P<profile>[^\/]+)" # there is almost no text that is invalid
54 54
55 55 default_handlers = [
56 56 (r"/clusters", MainClusterHandler),
57 57 (r"/clusters/%s/%s" % (_profile_regex, _cluster_action_regex), ClusterActionHandler),
58 58 (r"/clusters/%s" % _profile_regex, ClusterProfileHandler),
59 59 ]
@@ -1,44 +1,44 b''
1 1 """Tornado handlers for frontend config storage."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5 import json
6 6 import os
7 7 import io
8 8 import errno
9 9 from tornado import web
10 10
11 11 from IPython.utils.py3compat import PY3
12 from ...base.handlers import IPythonHandler, json_errors
12 from ...base.handlers import APIHandler, json_errors
13 13
14 class ConfigHandler(IPythonHandler):
14 class ConfigHandler(APIHandler):
15 15 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH')
16 16
17 17 @web.authenticated
18 18 @json_errors
19 19 def get(self, section_name):
20 20 self.set_header("Content-Type", 'application/json')
21 21 self.finish(json.dumps(self.config_manager.get(section_name)))
22 22
23 23 @web.authenticated
24 24 @json_errors
25 25 def put(self, section_name):
26 26 data = self.get_json_body() # Will raise 400 if content is not valid JSON
27 27 self.config_manager.set(section_name, data)
28 28 self.set_status(204)
29 29
30 30 @web.authenticated
31 31 @json_errors
32 32 def patch(self, section_name):
33 33 new_data = self.get_json_body()
34 34 section = self.config_manager.update(section_name, new_data)
35 35 self.finish(json.dumps(section))
36 36
37 37
38 38 # URL to handler mappings
39 39
40 40 section_name_regex = r"(?P<section_name>\w+)"
41 41
42 42 default_handlers = [
43 43 (r"/api/config/%s" % section_name_regex, ConfigHandler),
44 44 ]
@@ -1,342 +1,342 b''
1 1 """Tornado handlers for the contents web service."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import json
7 7
8 8 from tornado import gen, web
9 9
10 10 from IPython.html.utils import url_path_join, url_escape
11 11 from IPython.utils.jsonutil import date_default
12 12
13 13 from IPython.html.base.handlers import (
14 IPythonHandler, json_errors, path_regex,
14 IPythonHandler, APIHandler, json_errors, path_regex,
15 15 )
16 16
17 17
18 18 def sort_key(model):
19 19 """key function for case-insensitive sort by name and type"""
20 20 iname = model['name'].lower()
21 21 type_key = {
22 22 'directory' : '0',
23 23 'notebook' : '1',
24 24 'file' : '2',
25 25 }.get(model['type'], '9')
26 26 return u'%s%s' % (type_key, iname)
27 27
28 28
29 29 def validate_model(model, expect_content):
30 30 """
31 31 Validate a model returned by a ContentsManager method.
32 32
33 33 If expect_content is True, then we expect non-null entries for 'content'
34 34 and 'format'.
35 35 """
36 36 required_keys = {
37 37 "name",
38 38 "path",
39 39 "type",
40 40 "writable",
41 41 "created",
42 42 "last_modified",
43 43 "mimetype",
44 44 "content",
45 45 "format",
46 46 }
47 47 missing = required_keys - set(model.keys())
48 48 if missing:
49 49 raise web.HTTPError(
50 50 500,
51 51 u"Missing Model Keys: {missing}".format(missing=missing),
52 52 )
53 53
54 54 maybe_none_keys = ['content', 'format']
55 55 if model['type'] == 'file':
56 56 # mimetype should be populated only for file models
57 57 maybe_none_keys.append('mimetype')
58 58 if expect_content:
59 59 errors = [key for key in maybe_none_keys if model[key] is None]
60 60 if errors:
61 61 raise web.HTTPError(
62 62 500,
63 63 u"Keys unexpectedly None: {keys}".format(keys=errors),
64 64 )
65 65 else:
66 66 errors = {
67 67 key: model[key]
68 68 for key in maybe_none_keys
69 69 if model[key] is not None
70 70 }
71 71 if errors:
72 72 raise web.HTTPError(
73 73 500,
74 74 u"Keys unexpectedly not None: {keys}".format(keys=errors),
75 75 )
76 76
77 77
78 class ContentsHandler(IPythonHandler):
78 class ContentsHandler(APIHandler):
79 79
80 80 SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE')
81 81
82 82 def location_url(self, path):
83 83 """Return the full URL location of a file.
84 84
85 85 Parameters
86 86 ----------
87 87 path : unicode
88 88 The API path of the file, such as "foo/bar.txt".
89 89 """
90 90 return url_escape(url_path_join(
91 91 self.base_url, 'api', 'contents', path
92 92 ))
93 93
94 94 def _finish_model(self, model, location=True):
95 95 """Finish a JSON request with a model, setting relevant headers, etc."""
96 96 if location:
97 97 location = self.location_url(model['path'])
98 98 self.set_header('Location', location)
99 99 self.set_header('Last-Modified', model['last_modified'])
100 100 self.set_header('Content-Type', 'application/json')
101 101 self.finish(json.dumps(model, default=date_default))
102 102
103 103 @web.authenticated
104 104 @json_errors
105 105 @gen.coroutine
106 106 def get(self, path=''):
107 107 """Return a model for a file or directory.
108 108
109 109 A directory model contains a list of models (without content)
110 110 of the files and directories it contains.
111 111 """
112 112 path = path or ''
113 113 type = self.get_query_argument('type', default=None)
114 114 if type not in {None, 'directory', 'file', 'notebook'}:
115 115 raise web.HTTPError(400, u'Type %r is invalid' % type)
116 116
117 117 format = self.get_query_argument('format', default=None)
118 118 if format not in {None, 'text', 'base64'}:
119 119 raise web.HTTPError(400, u'Format %r is invalid' % format)
120 120 content = self.get_query_argument('content', default='1')
121 121 if content not in {'0', '1'}:
122 122 raise web.HTTPError(400, u'Content %r is invalid' % content)
123 123 content = int(content)
124 124
125 125 model = yield gen.maybe_future(self.contents_manager.get(
126 126 path=path, type=type, format=format, content=content,
127 127 ))
128 128 if model['type'] == 'directory' and content:
129 129 # group listing by type, then by name (case-insensitive)
130 130 # FIXME: sorting should be done in the frontends
131 131 model['content'].sort(key=sort_key)
132 132 validate_model(model, expect_content=content)
133 133 self._finish_model(model, location=False)
134 134
135 135 @web.authenticated
136 136 @json_errors
137 137 @gen.coroutine
138 138 def patch(self, path=''):
139 139 """PATCH renames a file or directory without re-uploading content."""
140 140 cm = self.contents_manager
141 141 model = self.get_json_body()
142 142 if model is None:
143 143 raise web.HTTPError(400, u'JSON body missing')
144 144 model = yield gen.maybe_future(cm.update(model, path))
145 145 validate_model(model, expect_content=False)
146 146 self._finish_model(model)
147 147
148 148 @gen.coroutine
149 149 def _copy(self, copy_from, copy_to=None):
150 150 """Copy a file, optionally specifying a target directory."""
151 151 self.log.info(u"Copying {copy_from} to {copy_to}".format(
152 152 copy_from=copy_from,
153 153 copy_to=copy_to or '',
154 154 ))
155 155 model = yield gen.maybe_future(self.contents_manager.copy(copy_from, copy_to))
156 156 self.set_status(201)
157 157 validate_model(model, expect_content=False)
158 158 self._finish_model(model)
159 159
160 160 @gen.coroutine
161 161 def _upload(self, model, path):
162 162 """Handle upload of a new file to path"""
163 163 self.log.info(u"Uploading file to %s", path)
164 164 model = yield gen.maybe_future(self.contents_manager.new(model, path))
165 165 self.set_status(201)
166 166 validate_model(model, expect_content=False)
167 167 self._finish_model(model)
168 168
169 169 @gen.coroutine
170 170 def _new_untitled(self, path, type='', ext=''):
171 171 """Create a new, empty untitled entity"""
172 172 self.log.info(u"Creating new %s in %s", type or 'file', path)
173 173 model = yield gen.maybe_future(self.contents_manager.new_untitled(path=path, type=type, ext=ext))
174 174 self.set_status(201)
175 175 validate_model(model, expect_content=False)
176 176 self._finish_model(model)
177 177
178 178 @gen.coroutine
179 179 def _save(self, model, path):
180 180 """Save an existing file."""
181 181 self.log.info(u"Saving file at %s", path)
182 182 model = yield gen.maybe_future(self.contents_manager.save(model, path))
183 183 validate_model(model, expect_content=False)
184 184 self._finish_model(model)
185 185
186 186 @web.authenticated
187 187 @json_errors
188 188 @gen.coroutine
189 189 def post(self, path=''):
190 190 """Create a new file in the specified path.
191 191
192 192 POST creates new files. The server always decides on the name.
193 193
194 194 POST /api/contents/path
195 195 New untitled, empty file or directory.
196 196 POST /api/contents/path
197 197 with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
198 198 New copy of OtherNotebook in path
199 199 """
200 200
201 201 cm = self.contents_manager
202 202
203 203 if cm.file_exists(path):
204 204 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
205 205
206 206 if not cm.dir_exists(path):
207 207 raise web.HTTPError(404, "No such directory: %s" % path)
208 208
209 209 model = self.get_json_body()
210 210
211 211 if model is not None:
212 212 copy_from = model.get('copy_from')
213 213 ext = model.get('ext', '')
214 214 type = model.get('type', '')
215 215 if copy_from:
216 216 yield self._copy(copy_from, path)
217 217 else:
218 218 yield self._new_untitled(path, type=type, ext=ext)
219 219 else:
220 220 yield self._new_untitled(path)
221 221
222 222 @web.authenticated
223 223 @json_errors
224 224 @gen.coroutine
225 225 def put(self, path=''):
226 226 """Saves the file in the location specified by name and path.
227 227
228 228 PUT is very similar to POST, but the requester specifies the name,
229 229 whereas with POST, the server picks the name.
230 230
231 231 PUT /api/contents/path/Name.ipynb
232 232 Save notebook at ``path/Name.ipynb``. Notebook structure is specified
233 233 in `content` key of JSON request body. If content is not specified,
234 234 create a new empty notebook.
235 235 """
236 236 model = self.get_json_body()
237 237 if model:
238 238 if model.get('copy_from'):
239 239 raise web.HTTPError(400, "Cannot copy with PUT, only POST")
240 240 exists = yield gen.maybe_future(self.contents_manager.file_exists(path))
241 241 if exists:
242 242 yield gen.maybe_future(self._save(model, path))
243 243 else:
244 244 yield gen.maybe_future(self._upload(model, path))
245 245 else:
246 246 yield gen.maybe_future(self._new_untitled(path))
247 247
248 248 @web.authenticated
249 249 @json_errors
250 250 @gen.coroutine
251 251 def delete(self, path=''):
252 252 """delete a file in the given path"""
253 253 cm = self.contents_manager
254 254 self.log.warn('delete %s', path)
255 255 yield gen.maybe_future(cm.delete(path))
256 256 self.set_status(204)
257 257 self.finish()
258 258
259 259
260 class CheckpointsHandler(IPythonHandler):
260 class CheckpointsHandler(APIHandler):
261 261
262 262 SUPPORTED_METHODS = ('GET', 'POST')
263 263
264 264 @web.authenticated
265 265 @json_errors
266 266 @gen.coroutine
267 267 def get(self, path=''):
268 268 """get lists checkpoints for a file"""
269 269 cm = self.contents_manager
270 270 checkpoints = yield gen.maybe_future(cm.list_checkpoints(path))
271 271 data = json.dumps(checkpoints, default=date_default)
272 272 self.finish(data)
273 273
274 274 @web.authenticated
275 275 @json_errors
276 276 @gen.coroutine
277 277 def post(self, path=''):
278 278 """post creates a new checkpoint"""
279 279 cm = self.contents_manager
280 280 checkpoint = yield gen.maybe_future(cm.create_checkpoint(path))
281 281 data = json.dumps(checkpoint, default=date_default)
282 282 location = url_path_join(self.base_url, 'api/contents',
283 283 path, 'checkpoints', checkpoint['id'])
284 284 self.set_header('Location', url_escape(location))
285 285 self.set_status(201)
286 286 self.finish(data)
287 287
288 288
289 class ModifyCheckpointsHandler(IPythonHandler):
289 class ModifyCheckpointsHandler(APIHandler):
290 290
291 291 SUPPORTED_METHODS = ('POST', 'DELETE')
292 292
293 293 @web.authenticated
294 294 @json_errors
295 295 @gen.coroutine
296 296 def post(self, path, checkpoint_id):
297 297 """post restores a file from a checkpoint"""
298 298 cm = self.contents_manager
299 299 yield gen.maybe_future(cm.restore_checkpoint(checkpoint_id, path))
300 300 self.set_status(204)
301 301 self.finish()
302 302
303 303 @web.authenticated
304 304 @json_errors
305 305 @gen.coroutine
306 306 def delete(self, path, checkpoint_id):
307 307 """delete clears a checkpoint for a given file"""
308 308 cm = self.contents_manager
309 309 yield gen.maybe_future(cm.delete_checkpoint(checkpoint_id, path))
310 310 self.set_status(204)
311 311 self.finish()
312 312
313 313
314 314 class NotebooksRedirectHandler(IPythonHandler):
315 315 """Redirect /api/notebooks to /api/contents"""
316 316 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
317 317
318 318 def get(self, path):
319 319 self.log.warn("/api/notebooks is deprecated, use /api/contents")
320 320 self.redirect(url_path_join(
321 321 self.base_url,
322 322 'api/contents',
323 323 path
324 324 ))
325 325
326 326 put = patch = post = delete = get
327 327
328 328
329 329 #-----------------------------------------------------------------------------
330 330 # URL to handler mappings
331 331 #-----------------------------------------------------------------------------
332 332
333 333
334 334 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
335 335
336 336 default_handlers = [
337 337 (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
338 338 (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
339 339 ModifyCheckpointsHandler),
340 340 (r"/api/contents%s" % path_regex, ContentsHandler),
341 341 (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
342 342 ]
@@ -1,288 +1,288 b''
1 1 """Tornado handlers for kernels."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import json
7 7 import logging
8 8 from tornado import gen, web
9 9 from tornado.concurrent import Future
10 10 from tornado.ioloop import IOLoop
11 11
12 12 from IPython.utils.jsonutil import date_default
13 13 from IPython.utils.py3compat import cast_unicode
14 14 from IPython.html.utils import url_path_join, url_escape
15 15
16 from ...base.handlers import IPythonHandler, json_errors
16 from ...base.handlers import IPythonHandler, APIHandler, json_errors
17 17 from ...base.zmqhandlers import AuthenticatedZMQStreamHandler, deserialize_binary_message
18 18
19 19 from IPython.core.release import kernel_protocol_version
20 20
21 class MainKernelHandler(IPythonHandler):
21 class MainKernelHandler(APIHandler):
22 22
23 23 @web.authenticated
24 24 @json_errors
25 25 def get(self):
26 26 km = self.kernel_manager
27 27 self.finish(json.dumps(km.list_kernels()))
28 28
29 29 @web.authenticated
30 30 @json_errors
31 31 def post(self):
32 32 km = self.kernel_manager
33 33 model = self.get_json_body()
34 34 if model is None:
35 35 model = {
36 36 'name': km.default_kernel_name
37 37 }
38 38 else:
39 39 model.setdefault('name', km.default_kernel_name)
40 40
41 41 kernel_id = km.start_kernel(kernel_name=model['name'])
42 42 model = km.kernel_model(kernel_id)
43 43 location = url_path_join(self.base_url, 'api', 'kernels', kernel_id)
44 44 self.set_header('Location', url_escape(location))
45 45 self.set_status(201)
46 46 self.finish(json.dumps(model))
47 47
48 48
49 class KernelHandler(IPythonHandler):
49 class KernelHandler(APIHandler):
50 50
51 51 SUPPORTED_METHODS = ('DELETE', 'GET')
52 52
53 53 @web.authenticated
54 54 @json_errors
55 55 def get(self, kernel_id):
56 56 km = self.kernel_manager
57 57 km._check_kernel_id(kernel_id)
58 58 model = km.kernel_model(kernel_id)
59 59 self.finish(json.dumps(model))
60 60
61 61 @web.authenticated
62 62 @json_errors
63 63 def delete(self, kernel_id):
64 64 km = self.kernel_manager
65 65 km.shutdown_kernel(kernel_id)
66 66 self.set_status(204)
67 67 self.finish()
68 68
69 69
70 class KernelActionHandler(IPythonHandler):
70 class KernelActionHandler(APIHandler):
71 71
72 72 @web.authenticated
73 73 @json_errors
74 74 def post(self, kernel_id, action):
75 75 km = self.kernel_manager
76 76 if action == 'interrupt':
77 77 km.interrupt_kernel(kernel_id)
78 78 self.set_status(204)
79 79 if action == 'restart':
80 80 km.restart_kernel(kernel_id)
81 81 model = km.kernel_model(kernel_id)
82 82 self.set_header('Location', '{0}api/kernels/{1}'.format(self.base_url, kernel_id))
83 83 self.write(json.dumps(model))
84 84 self.finish()
85 85
86 86
87 87 class ZMQChannelsHandler(AuthenticatedZMQStreamHandler):
88 88
89 89 @property
90 90 def kernel_info_timeout(self):
91 91 return self.settings.get('kernel_info_timeout', 10)
92 92
93 93 def __repr__(self):
94 94 return "%s(%s)" % (self.__class__.__name__, getattr(self, 'kernel_id', 'uninitialized'))
95 95
96 96 def create_stream(self):
97 97 km = self.kernel_manager
98 98 identity = self.session.bsession
99 99 for channel in ('shell', 'iopub', 'stdin'):
100 100 meth = getattr(km, 'connect_' + channel)
101 101 self.channels[channel] = stream = meth(self.kernel_id, identity=identity)
102 102 stream.channel = channel
103 103 km.add_restart_callback(self.kernel_id, self.on_kernel_restarted)
104 104 km.add_restart_callback(self.kernel_id, self.on_restart_failed, 'dead')
105 105
106 106 def request_kernel_info(self):
107 107 """send a request for kernel_info"""
108 108 km = self.kernel_manager
109 109 kernel = km.get_kernel(self.kernel_id)
110 110 try:
111 111 # check for previous request
112 112 future = kernel._kernel_info_future
113 113 except AttributeError:
114 114 self.log.debug("Requesting kernel info from %s", self.kernel_id)
115 115 # Create a kernel_info channel to query the kernel protocol version.
116 116 # This channel will be closed after the kernel_info reply is received.
117 117 if self.kernel_info_channel is None:
118 118 self.kernel_info_channel = km.connect_shell(self.kernel_id)
119 119 self.kernel_info_channel.on_recv(self._handle_kernel_info_reply)
120 120 self.session.send(self.kernel_info_channel, "kernel_info_request")
121 121 # store the future on the kernel, so only one request is sent
122 122 kernel._kernel_info_future = self._kernel_info_future
123 123 else:
124 124 if not future.done():
125 125 self.log.debug("Waiting for pending kernel_info request")
126 126 future.add_done_callback(lambda f: self._finish_kernel_info(f.result()))
127 127 return self._kernel_info_future
128 128
129 129 def _handle_kernel_info_reply(self, msg):
130 130 """process the kernel_info_reply
131 131
132 132 enabling msg spec adaptation, if necessary
133 133 """
134 134 idents,msg = self.session.feed_identities(msg)
135 135 try:
136 136 msg = self.session.deserialize(msg)
137 137 except:
138 138 self.log.error("Bad kernel_info reply", exc_info=True)
139 139 self._kernel_info_future.set_result({})
140 140 return
141 141 else:
142 142 info = msg['content']
143 143 self.log.debug("Received kernel info: %s", info)
144 144 if msg['msg_type'] != 'kernel_info_reply' or 'protocol_version' not in info:
145 145 self.log.error("Kernel info request failed, assuming current %s", info)
146 146 info = {}
147 147 self._finish_kernel_info(info)
148 148
149 149 # close the kernel_info channel, we don't need it anymore
150 150 if self.kernel_info_channel:
151 151 self.kernel_info_channel.close()
152 152 self.kernel_info_channel = None
153 153
154 154 def _finish_kernel_info(self, info):
155 155 """Finish handling kernel_info reply
156 156
157 157 Set up protocol adaptation, if needed,
158 158 and signal that connection can continue.
159 159 """
160 160 protocol_version = info.get('protocol_version', kernel_protocol_version)
161 161 if protocol_version != kernel_protocol_version:
162 162 self.session.adapt_version = int(protocol_version.split('.')[0])
163 163 self.log.info("Adapting to protocol v%s for kernel %s", protocol_version, self.kernel_id)
164 164 if not self._kernel_info_future.done():
165 165 self._kernel_info_future.set_result(info)
166 166
167 167 def initialize(self):
168 168 super(ZMQChannelsHandler, self).initialize()
169 169 self.zmq_stream = None
170 170 self.channels = {}
171 171 self.kernel_id = None
172 172 self.kernel_info_channel = None
173 173 self._kernel_info_future = Future()
174 174
175 175 @gen.coroutine
176 176 def pre_get(self):
177 177 # authenticate first
178 178 super(ZMQChannelsHandler, self).pre_get()
179 179 # then request kernel info, waiting up to a certain time before giving up.
180 180 # We don't want to wait forever, because browsers don't take it well when
181 181 # servers never respond to websocket connection requests.
182 182 kernel = self.kernel_manager.get_kernel(self.kernel_id)
183 183 self.session.key = kernel.session.key
184 184 future = self.request_kernel_info()
185 185
186 186 def give_up():
187 187 """Don't wait forever for the kernel to reply"""
188 188 if future.done():
189 189 return
190 190 self.log.warn("Timeout waiting for kernel_info reply from %s", self.kernel_id)
191 191 future.set_result({})
192 192 loop = IOLoop.current()
193 193 loop.add_timeout(loop.time() + self.kernel_info_timeout, give_up)
194 194 # actually wait for it
195 195 yield future
196 196
197 197 @gen.coroutine
198 198 def get(self, kernel_id):
199 199 self.kernel_id = cast_unicode(kernel_id, 'ascii')
200 200 yield super(ZMQChannelsHandler, self).get(kernel_id=kernel_id)
201 201
202 202 def open(self, kernel_id):
203 203 super(ZMQChannelsHandler, self).open()
204 204 try:
205 205 self.create_stream()
206 206 except web.HTTPError as e:
207 207 self.log.error("Error opening stream: %s", e)
208 208 # WebSockets don't response to traditional error codes so we
209 209 # close the connection.
210 210 for channel, stream in self.channels.items():
211 211 if not stream.closed():
212 212 stream.close()
213 213 self.close()
214 214 else:
215 215 for channel, stream in self.channels.items():
216 216 stream.on_recv_stream(self._on_zmq_reply)
217 217
218 218 def on_message(self, msg):
219 219 if not self.channels:
220 220 # already closed, ignore the message
221 221 self.log.debug("Received message on closed websocket %r", msg)
222 222 return
223 223 if isinstance(msg, bytes):
224 224 msg = deserialize_binary_message(msg)
225 225 else:
226 226 msg = json.loads(msg)
227 227 channel = msg.pop('channel', None)
228 228 if channel is None:
229 229 self.log.warn("No channel specified, assuming shell: %s", msg)
230 230 channel = 'shell'
231 231 if channel not in self.channels:
232 232 self.log.warn("No such channel: %r", channel)
233 233 return
234 234 stream = self.channels[channel]
235 235 self.session.send(stream, msg)
236 236
237 237 def on_close(self):
238 238 km = self.kernel_manager
239 239 if self.kernel_id in km:
240 240 km.remove_restart_callback(
241 241 self.kernel_id, self.on_kernel_restarted,
242 242 )
243 243 km.remove_restart_callback(
244 244 self.kernel_id, self.on_restart_failed, 'dead',
245 245 )
246 246 # This method can be called twice, once by self.kernel_died and once
247 247 # from the WebSocket close event. If the WebSocket connection is
248 248 # closed before the ZMQ streams are setup, they could be None.
249 249 for channel, stream in self.channels.items():
250 250 if stream is not None and not stream.closed():
251 251 stream.on_recv(None)
252 252 # close the socket directly, don't wait for the stream
253 253 socket = stream.socket
254 254 stream.close()
255 255 socket.close()
256 256
257 257 self.channels = {}
258 258
259 259 def _send_status_message(self, status):
260 260 msg = self.session.msg("status",
261 261 {'execution_state': status}
262 262 )
263 263 msg['channel'] = 'iopub'
264 264 self.write_message(json.dumps(msg, default=date_default))
265 265
266 266 def on_kernel_restarted(self):
267 267 logging.warn("kernel %s restarted", self.kernel_id)
268 268 self._send_status_message('restarting')
269 269
270 270 def on_restart_failed(self):
271 271 logging.error("kernel %s restarted failed!", self.kernel_id)
272 272 self._send_status_message('dead')
273 273
274 274
275 275 #-----------------------------------------------------------------------------
276 276 # URL to handler mappings
277 277 #-----------------------------------------------------------------------------
278 278
279 279
280 280 _kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"
281 281 _kernel_action_regex = r"(?P<action>restart|interrupt)"
282 282
283 283 default_handlers = [
284 284 (r"/api/kernels", MainKernelHandler),
285 285 (r"/api/kernels/%s" % _kernel_id_regex, KernelHandler),
286 286 (r"/api/kernels/%s/%s" % (_kernel_id_regex, _kernel_action_regex), KernelActionHandler),
287 287 (r"/api/kernels/%s/channels" % _kernel_id_regex, ZMQChannelsHandler),
288 288 ]
@@ -1,139 +1,141 b''
1 1 """Test the kernels service API."""
2 2
3 3 import json
4 4 import requests
5 5
6 6 from IPython.html.utils import url_path_join
7 7 from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error
8 8
9 9 class KernelAPI(object):
10 10 """Wrapper for kernel REST API requests"""
11 11 def __init__(self, base_url):
12 12 self.base_url = base_url
13 13
14 14 def _req(self, verb, path, body=None):
15 15 response = requests.request(verb,
16 16 url_path_join(self.base_url, 'api/kernels', path), data=body)
17 17
18 18 if 400 <= response.status_code < 600:
19 19 try:
20 20 response.reason = response.json()['message']
21 21 except:
22 22 pass
23 23 response.raise_for_status()
24 24
25 25 return response
26 26
27 27 def list(self):
28 28 return self._req('GET', '')
29 29
30 30 def get(self, id):
31 31 return self._req('GET', id)
32 32
33 33 def start(self, name='python'):
34 34 body = json.dumps({'name': name})
35 35 return self._req('POST', '', body)
36 36
37 37 def shutdown(self, id):
38 38 return self._req('DELETE', id)
39 39
40 40 def interrupt(self, id):
41 41 return self._req('POST', url_path_join(id, 'interrupt'))
42 42
43 43 def restart(self, id):
44 44 return self._req('POST', url_path_join(id, 'restart'))
45 45
46 46 class KernelAPITest(NotebookTestBase):
47 47 """Test the kernels web service API"""
48 48 def setUp(self):
49 49 self.kern_api = KernelAPI(self.base_url())
50 50
51 51 def tearDown(self):
52 52 for k in self.kern_api.list().json():
53 53 self.kern_api.shutdown(k['id'])
54 54
55 55 def test__no_kernels(self):
56 56 """Make sure there are no kernels running at the start"""
57 57 kernels = self.kern_api.list().json()
58 58 self.assertEqual(kernels, [])
59 59
60 60 def test_default_kernel(self):
61 61 # POST request
62 62 r = self.kern_api._req('POST', '')
63 63 kern1 = r.json()
64 64 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
65 65 self.assertEqual(r.status_code, 201)
66 66 self.assertIsInstance(kern1, dict)
67 67
68 68 self.assertEqual(r.headers['Content-Security-Policy'], (
69 69 "frame-ancestors 'self'; "
70 "report-uri /api/security/csp-report;"
70 "report-uri /api/security/csp-report; "
71 "default-src 'none'"
71 72 ))
72 73
73 74 def test_main_kernel_handler(self):
74 75 # POST request
75 76 r = self.kern_api.start()
76 77 kern1 = r.json()
77 78 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
78 79 self.assertEqual(r.status_code, 201)
79 80 self.assertIsInstance(kern1, dict)
80 81
81 82 self.assertEqual(r.headers['Content-Security-Policy'], (
82 83 "frame-ancestors 'self'; "
83 "report-uri /api/security/csp-report;"
84 "report-uri /api/security/csp-report; "
85 "default-src 'none'"
84 86 ))
85 87
86 88 # GET request
87 89 r = self.kern_api.list()
88 90 self.assertEqual(r.status_code, 200)
89 91 assert isinstance(r.json(), list)
90 92 self.assertEqual(r.json()[0]['id'], kern1['id'])
91 93 self.assertEqual(r.json()[0]['name'], kern1['name'])
92 94
93 95 # create another kernel and check that they both are added to the
94 96 # list of kernels from a GET request
95 97 kern2 = self.kern_api.start().json()
96 98 assert isinstance(kern2, dict)
97 99 r = self.kern_api.list()
98 100 kernels = r.json()
99 101 self.assertEqual(r.status_code, 200)
100 102 assert isinstance(kernels, list)
101 103 self.assertEqual(len(kernels), 2)
102 104
103 105 # Interrupt a kernel
104 106 r = self.kern_api.interrupt(kern2['id'])
105 107 self.assertEqual(r.status_code, 204)
106 108
107 109 # Restart a kernel
108 110 r = self.kern_api.restart(kern2['id'])
109 111 self.assertEqual(r.headers['Location'], '/api/kernels/'+kern2['id'])
110 112 rekern = r.json()
111 113 self.assertEqual(rekern['id'], kern2['id'])
112 114 self.assertEqual(rekern['name'], kern2['name'])
113 115
114 116 def test_kernel_handler(self):
115 117 # GET kernel with given id
116 118 kid = self.kern_api.start().json()['id']
117 119 r = self.kern_api.get(kid)
118 120 kern1 = r.json()
119 121 self.assertEqual(r.status_code, 200)
120 122 assert isinstance(kern1, dict)
121 123 self.assertIn('id', kern1)
122 124 self.assertEqual(kern1['id'], kid)
123 125
124 126 # Request a bad kernel id and check that a JSON
125 127 # message is returned!
126 128 bad_id = '111-111-111-111-111'
127 129 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
128 130 self.kern_api.get(bad_id)
129 131
130 132 # DELETE kernel with id
131 133 r = self.kern_api.shutdown(kid)
132 134 self.assertEqual(r.status_code, 204)
133 135 kernels = self.kern_api.list().json()
134 136 self.assertEqual(kernels, [])
135 137
136 138 # Request to delete a non-existent kernel id
137 139 bad_id = '111-111-111-111-111'
138 140 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
139 141 self.kern_api.shutdown(bad_id)
@@ -1,86 +1,86 b''
1 1 """Tornado handlers for kernel specifications."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import glob
7 7 import json
8 8 import os
9 9 pjoin = os.path.join
10 10
11 11 from tornado import web
12 12
13 from ...base.handlers import IPythonHandler, json_errors
13 from ...base.handlers import APIHandler, json_errors
14 14 from ...utils import url_path_join
15 15
16 16 def kernelspec_model(handler, name):
17 17 """Load a KernelSpec by name and return the REST API model"""
18 18 ksm = handler.kernel_spec_manager
19 19 spec = ksm.get_kernel_spec(name)
20 20 d = {'name': name}
21 21 d['spec'] = spec.to_dict()
22 22 d['resources'] = resources = {}
23 23 resource_dir = spec.resource_dir
24 24 for resource in ['kernel.js', 'kernel.css']:
25 25 if os.path.exists(pjoin(resource_dir, resource)):
26 26 resources[resource] = url_path_join(
27 27 handler.base_url,
28 28 'kernelspecs',
29 29 name,
30 30 resource
31 31 )
32 32 for logo_file in glob.glob(pjoin(resource_dir, 'logo-*')):
33 33 fname = os.path.basename(logo_file)
34 34 no_ext, _ = os.path.splitext(fname)
35 35 resources[no_ext] = url_path_join(
36 36 handler.base_url,
37 37 'kernelspecs',
38 38 name,
39 39 fname
40 40 )
41 41 return d
42 42
43 class MainKernelSpecHandler(IPythonHandler):
43 class MainKernelSpecHandler(APIHandler):
44 44 SUPPORTED_METHODS = ('GET',)
45 45
46 46 @web.authenticated
47 47 @json_errors
48 48 def get(self):
49 49 ksm = self.kernel_spec_manager
50 50 km = self.kernel_manager
51 51 model = {}
52 52 model['default'] = km.default_kernel_name
53 53 model['kernelspecs'] = specs = {}
54 54 for kernel_name in ksm.find_kernel_specs():
55 55 try:
56 56 d = kernelspec_model(self, kernel_name)
57 57 except Exception:
58 58 self.log.error("Failed to load kernel spec: '%s'", kernel_name, exc_info=True)
59 59 continue
60 60 specs[kernel_name] = d
61 61 self.set_header("Content-Type", 'application/json')
62 62 self.finish(json.dumps(model))
63 63
64 64
65 class KernelSpecHandler(IPythonHandler):
65 class KernelSpecHandler(APIHandler):
66 66 SUPPORTED_METHODS = ('GET',)
67 67
68 68 @web.authenticated
69 69 @json_errors
70 70 def get(self, kernel_name):
71 71 try:
72 72 model = kernelspec_model(self, kernel_name)
73 73 except KeyError:
74 74 raise web.HTTPError(404, u'Kernel spec %s not found' % kernel_name)
75 75 self.set_header("Content-Type", 'application/json')
76 76 self.finish(json.dumps(model))
77 77
78 78
79 79 # URL to handler mappings
80 80
81 81 kernel_name_regex = r"(?P<kernel_name>\w+)"
82 82
83 83 default_handlers = [
84 84 (r"/api/kernelspecs", MainKernelSpecHandler),
85 85 (r"/api/kernelspecs/%s" % kernel_name_regex, KernelSpecHandler),
86 86 ]
@@ -1,26 +1,26 b''
1 1 import json
2 2
3 3 from tornado import web
4 4
5 from ...base.handlers import IPythonHandler, json_errors
5 from ...base.handlers import APIHandler, json_errors
6 6
7 class NbconvertRootHandler(IPythonHandler):
7 class NbconvertRootHandler(APIHandler):
8 8 SUPPORTED_METHODS = ('GET',)
9 9
10 10 @web.authenticated
11 11 @json_errors
12 12 def get(self):
13 13 try:
14 14 from IPython.nbconvert.exporters.export import exporter_map
15 15 except ImportError as e:
16 16 raise web.HTTPError(500, "Could not import nbconvert: %s" % e)
17 17 res = {}
18 18 for format, exporter in exporter_map.items():
19 19 res[format] = info = {}
20 20 info['output_mimetype'] = exporter.output_mimetype
21 21
22 22 self.finish(json.dumps(res))
23 23
24 24 default_handlers = [
25 25 (r"/api/nbconvert", NbconvertRootHandler),
26 26 ] No newline at end of file
@@ -1,23 +1,23 b''
1 1 """Tornado handlers for security logging."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 from tornado import gen, web
7 7
8 from ...base.handlers import IPythonHandler, json_errors
8 from ...base.handlers import APIHandler, json_errors
9 9 from . import csp_report_uri
10 10
11 class CSPReportHandler(IPythonHandler):
11 class CSPReportHandler(APIHandler):
12 12 '''Accepts a content security policy violation report'''
13 13 @web.authenticated
14 14 @json_errors
15 15 def post(self):
16 16 '''Log a content security policy violation report'''
17 17 csp_report = self.get_json_body()
18 18 self.log.warn("Content security violation: %s",
19 19 self.request.body.decode('utf8', 'replace'))
20 20
21 21 default_handlers = [
22 22 (csp_report_uri, CSPReportHandler)
23 23 ]
@@ -1,122 +1,122 b''
1 1 """Tornado handlers for the sessions web service."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import json
7 7
8 8 from tornado import web
9 9
10 from ...base.handlers import IPythonHandler, json_errors
10 from ...base.handlers import APIHandler, json_errors
11 11 from IPython.utils.jsonutil import date_default
12 12 from IPython.html.utils import url_path_join, url_escape
13 13 from IPython.kernel.kernelspec import NoSuchKernel
14 14
15 15
16 class SessionRootHandler(IPythonHandler):
16 class SessionRootHandler(APIHandler):
17 17
18 18 @web.authenticated
19 19 @json_errors
20 20 def get(self):
21 21 # Return a list of running sessions
22 22 sm = self.session_manager
23 23 sessions = sm.list_sessions()
24 24 self.finish(json.dumps(sessions, default=date_default))
25 25
26 26 @web.authenticated
27 27 @json_errors
28 28 def post(self):
29 29 # Creates a new session
30 30 #(unless a session already exists for the named nb)
31 31 sm = self.session_manager
32 32 cm = self.contents_manager
33 33 km = self.kernel_manager
34 34
35 35 model = self.get_json_body()
36 36 if model is None:
37 37 raise web.HTTPError(400, "No JSON data provided")
38 38 try:
39 39 path = model['notebook']['path']
40 40 except KeyError:
41 41 raise web.HTTPError(400, "Missing field in JSON data: notebook.path")
42 42 try:
43 43 kernel_name = model['kernel']['name']
44 44 except KeyError:
45 45 self.log.debug("No kernel name specified, using default kernel")
46 46 kernel_name = None
47 47
48 48 # Check to see if session exists
49 49 if sm.session_exists(path=path):
50 50 model = sm.get_session(path=path)
51 51 else:
52 52 try:
53 53 model = sm.create_session(path=path, kernel_name=kernel_name)
54 54 except NoSuchKernel:
55 55 msg = ("The '%s' kernel is not available. Please pick another "
56 56 "suitable kernel instead, or install that kernel." % kernel_name)
57 57 status_msg = '%s not found' % kernel_name
58 58 self.log.warn('Kernel not found: %s' % kernel_name)
59 59 self.set_status(501)
60 60 self.finish(json.dumps(dict(message=msg, short_message=status_msg)))
61 61 return
62 62
63 63 location = url_path_join(self.base_url, 'api', 'sessions', model['id'])
64 64 self.set_header('Location', url_escape(location))
65 65 self.set_status(201)
66 66 self.finish(json.dumps(model, default=date_default))
67 67
68 class SessionHandler(IPythonHandler):
68 class SessionHandler(APIHandler):
69 69
70 70 SUPPORTED_METHODS = ('GET', 'PATCH', 'DELETE')
71 71
72 72 @web.authenticated
73 73 @json_errors
74 74 def get(self, session_id):
75 75 # Returns the JSON model for a single session
76 76 sm = self.session_manager
77 77 model = sm.get_session(session_id=session_id)
78 78 self.finish(json.dumps(model, default=date_default))
79 79
80 80 @web.authenticated
81 81 @json_errors
82 82 def patch(self, session_id):
83 83 # Currently, this handler is strictly for renaming notebooks
84 84 sm = self.session_manager
85 85 model = self.get_json_body()
86 86 if model is None:
87 87 raise web.HTTPError(400, "No JSON data provided")
88 88 changes = {}
89 89 if 'notebook' in model:
90 90 notebook = model['notebook']
91 91 if 'path' in notebook:
92 92 changes['path'] = notebook['path']
93 93
94 94 sm.update_session(session_id, **changes)
95 95 model = sm.get_session(session_id=session_id)
96 96 self.finish(json.dumps(model, default=date_default))
97 97
98 98 @web.authenticated
99 99 @json_errors
100 100 def delete(self, session_id):
101 101 # Deletes the session with given session_id
102 102 sm = self.session_manager
103 103 try:
104 104 sm.delete_session(session_id)
105 105 except KeyError:
106 106 # the kernel was deleted but the session wasn't!
107 107 raise web.HTTPError(410, "Kernel deleted before session")
108 108 self.set_status(204)
109 109 self.finish()
110 110
111 111
112 112 #-----------------------------------------------------------------------------
113 113 # URL to handler mappings
114 114 #-----------------------------------------------------------------------------
115 115
116 116 _session_id_regex = r"(?P<session_id>\w+-\w+-\w+-\w+-\w+)"
117 117
118 118 default_handlers = [
119 119 (r"/api/sessions/%s" % _session_id_regex, SessionHandler),
120 120 (r"/api/sessions", SessionRootHandler)
121 121 ]
122 122
@@ -1,44 +1,44 b''
1 1 import json
2 2 from tornado import web, gen
3 from ..base.handlers import IPythonHandler, json_errors
3 from ..base.handlers import APIHandler, json_errors
4 4 from ..utils import url_path_join
5 5
6 class TerminalRootHandler(IPythonHandler):
6 class TerminalRootHandler(APIHandler):
7 7 @web.authenticated
8 8 @json_errors
9 9 def get(self):
10 10 tm = self.terminal_manager
11 11 terms = [{'name': name} for name in tm.terminals]
12 12 self.finish(json.dumps(terms))
13 13
14 14 @web.authenticated
15 15 @json_errors
16 16 def post(self):
17 17 """POST /terminals creates a new terminal and redirects to it"""
18 18 name, _ = self.terminal_manager.new_named_terminal()
19 19 self.finish(json.dumps({'name': name}))
20 20
21 21
22 class TerminalHandler(IPythonHandler):
22 class TerminalHandler(APIHandler):
23 23 SUPPORTED_METHODS = ('GET', 'DELETE')
24 24
25 25 @web.authenticated
26 26 @json_errors
27 27 def get(self, name):
28 28 tm = self.terminal_manager
29 29 if name in tm.terminals:
30 30 self.finish(json.dumps({'name': name}))
31 31 else:
32 32 raise web.HTTPError(404, "Terminal not found: %r" % name)
33 33
34 34 @web.authenticated
35 35 @json_errors
36 36 @gen.coroutine
37 37 def delete(self, name):
38 38 tm = self.terminal_manager
39 39 if name in tm.terminals:
40 40 yield tm.terminate(name, force=True)
41 41 self.set_status(204)
42 42 self.finish()
43 43 else:
44 44 raise web.HTTPError(404, "Terminal not found: %r" % name)
General Comments 0
You need to be logged in to leave comments. Login now