##// END OF EJS Templates
Merge pull request #6694 from takluyver/config-rest-api...
Min RK -
r18816:01354ade merge
parent child Browse files
Show More
1 NO CONTENT: new file 100644
@@ -0,0 +1,102
1 """Tornado handlers for frontend config storage."""
2
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
5 import json
6 import os
7 import io
8 import errno
9 from tornado import web
10
11 from IPython.utils.py3compat import PY3
12 from ...base.handlers import IPythonHandler, json_errors
13
14 def recursive_update(target, new):
15 """Recursively update one dictionary using another.
16
17 None values will delete their keys.
18 """
19 for k, v in new.items():
20 if isinstance(v, dict):
21 if k not in target:
22 target[k] = {}
23 recursive_update(target[k], v)
24 if not target[k]:
25 # Prune empty subdicts
26 del target[k]
27
28 elif v is None:
29 target.pop(k, None)
30
31 else:
32 target[k] = v
33
34 class ConfigHandler(IPythonHandler):
35 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH')
36
37 @property
38 def config_dir(self):
39 return os.path.join(self.profile_dir, 'nbconfig')
40
41 def ensure_config_dir_exists(self):
42 try:
43 os.mkdir(self.config_dir, 0o755)
44 except OSError as e:
45 if e.errno != errno.EEXIST:
46 raise
47
48 def file_name(self, section_name):
49 return os.path.join(self.config_dir, section_name+'.json')
50
51 @web.authenticated
52 @json_errors
53 def get(self, section_name):
54 self.set_header("Content-Type", 'application/json')
55 filename = self.file_name(section_name)
56 if os.path.isfile(filename):
57 with io.open(filename, encoding='utf-8') as f:
58 self.finish(f.read())
59 else:
60 self.finish("{}")
61
62 @web.authenticated
63 @json_errors
64 def put(self, section_name):
65 self.get_json_body() # Will raise 400 if content is not valid JSON
66 filename = self.file_name(section_name)
67 self.ensure_config_dir_exists()
68 with open(filename, 'wb') as f:
69 f.write(self.request.body)
70 self.set_status(204)
71
72 @web.authenticated
73 @json_errors
74 def patch(self, section_name):
75 filename = self.file_name(section_name)
76 if os.path.isfile(filename):
77 with io.open(filename, encoding='utf-8') as f:
78 section = json.load(f)
79 else:
80 section = {}
81
82 update = self.get_json_body()
83 recursive_update(section, update)
84
85 self.ensure_config_dir_exists()
86 if PY3:
87 f = io.open(filename, 'w', encoding='utf-8')
88 else:
89 f = open(filename, 'wb')
90 with f:
91 json.dump(section, f)
92
93 self.finish(json.dumps(section))
94
95
96 # URL to handler mappings
97
98 section_name_regex = r"(?P<section_name>\w+)"
99
100 default_handlers = [
101 (r"/api/config/%s" % section_name_regex, ConfigHandler),
102 ]
1 NO CONTENT: new file 100644
@@ -0,0 +1,68
1 # coding: utf-8
2 """Test the config webservice API."""
3
4 import json
5
6 import requests
7
8 from IPython.html.utils import url_path_join
9 from IPython.html.tests.launchnotebook import NotebookTestBase
10
11
12 class ConfigAPI(object):
13 """Wrapper for notebook API calls."""
14 def __init__(self, base_url):
15 self.base_url = base_url
16
17 def _req(self, verb, section, body=None):
18 response = requests.request(verb,
19 url_path_join(self.base_url, 'api/config', section),
20 data=body,
21 )
22 response.raise_for_status()
23 return response
24
25 def get(self, section):
26 return self._req('GET', section)
27
28 def set(self, section, values):
29 return self._req('PUT', section, json.dumps(values))
30
31 def modify(self, section, values):
32 return self._req('PATCH', section, json.dumps(values))
33
34 class APITest(NotebookTestBase):
35 """Test the config web service API"""
36 def setUp(self):
37 self.config_api = ConfigAPI(self.base_url())
38
39 def test_create_retrieve_config(self):
40 sample = {'foo': 'bar', 'baz': 73}
41 r = self.config_api.set('example', sample)
42 self.assertEqual(r.status_code, 204)
43
44 r = self.config_api.get('example')
45 self.assertEqual(r.status_code, 200)
46 self.assertEqual(r.json(), sample)
47
48 def test_modify(self):
49 sample = {'foo': 'bar', 'baz': 73,
50 'sub': {'a': 6, 'b': 7}, 'sub2': {'c': 8}}
51 self.config_api.set('example', sample)
52
53 r = self.config_api.modify('example', {'foo': None, # should delete foo
54 'baz': 75,
55 'wib': [1,2,3],
56 'sub': {'a': 8, 'b': None, 'd': 9},
57 'sub2': {'c': None} # should delete sub2
58 })
59 self.assertEqual(r.status_code, 200)
60 self.assertEqual(r.json(), {'baz': 75, 'wib': [1,2,3],
61 'sub': {'a': 8, 'd': 9}})
62
63 def test_get_unknown(self):
64 # We should get an empty config dictionary instead of a 404
65 r = self.config_api.get('nonexistant')
66 self.assertEqual(r.status_code, 200)
67 self.assertEqual(r.json(), {})
68
@@ -1,482 +1,486
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 try:
23 23 from tornado.log import app_log
24 24 except ImportError:
25 25 app_log = logging.getLogger()
26 26
27 27 import IPython
28 28 from IPython.utils.sysinfo import get_sys_info
29 29
30 30 from IPython.config import Application
31 31 from IPython.utils.path import filefind
32 32 from IPython.utils.py3compat import string_types
33 33 from IPython.html.utils import is_hidden, url_path_join, url_escape
34 34
35 35 #-----------------------------------------------------------------------------
36 36 # Top-level handlers
37 37 #-----------------------------------------------------------------------------
38 38 non_alphanum = re.compile(r'[^A-Za-z0-9]')
39 39
40 40 sys_info = json.dumps(get_sys_info())
41 41
42 42 class AuthenticatedHandler(web.RequestHandler):
43 43 """A RequestHandler with an authenticated user."""
44 44
45 45 def set_default_headers(self):
46 46 headers = self.settings.get('headers', {})
47 47
48 48 if "X-Frame-Options" not in headers:
49 49 headers["X-Frame-Options"] = "SAMEORIGIN"
50 50
51 51 for header_name,value in headers.items() :
52 52 try:
53 53 self.set_header(header_name, value)
54 54 except Exception:
55 55 # tornado raise Exception (not a subclass)
56 56 # if method is unsupported (websocket and Access-Control-Allow-Origin
57 57 # for example, so just ignore)
58 58 pass
59 59
60 60 def clear_login_cookie(self):
61 61 self.clear_cookie(self.cookie_name)
62 62
63 63 def get_current_user(self):
64 64 user_id = self.get_secure_cookie(self.cookie_name)
65 65 # For now the user_id should not return empty, but it could eventually
66 66 if user_id == '':
67 67 user_id = 'anonymous'
68 68 if user_id is None:
69 69 # prevent extra Invalid cookie sig warnings:
70 70 self.clear_login_cookie()
71 71 if not self.login_available:
72 72 user_id = 'anonymous'
73 73 return user_id
74 74
75 75 @property
76 76 def cookie_name(self):
77 77 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
78 78 self.request.host
79 79 ))
80 80 return self.settings.get('cookie_name', default_cookie_name)
81 81
82 82 @property
83 83 def password(self):
84 84 """our password"""
85 85 return self.settings.get('password', '')
86 86
87 87 @property
88 88 def logged_in(self):
89 89 """Is a user currently logged in?
90 90
91 91 """
92 92 user = self.get_current_user()
93 93 return (user and not user == 'anonymous')
94 94
95 95 @property
96 96 def login_available(self):
97 97 """May a user proceed to log in?
98 98
99 99 This returns True if login capability is available, irrespective of
100 100 whether the user is already logged in or not.
101 101
102 102 """
103 103 return bool(self.settings.get('password', ''))
104 104
105 105
106 106 class IPythonHandler(AuthenticatedHandler):
107 107 """IPython-specific extensions to authenticated handling
108 108
109 109 Mostly property shortcuts to IPython-specific settings.
110 110 """
111 111
112 112 @property
113 113 def config(self):
114 114 return self.settings.get('config', None)
115 115
116 116 @property
117 117 def log(self):
118 118 """use the IPython log by default, falling back on tornado's logger"""
119 119 if Application.initialized():
120 120 return Application.instance().log
121 121 else:
122 122 return app_log
123 123
124 @property
125 def profile_dir(self):
126 return self.settings.get('profile_dir', '')
127
124 128 #---------------------------------------------------------------
125 129 # URLs
126 130 #---------------------------------------------------------------
127 131
128 132 @property
129 133 def mathjax_url(self):
130 134 return self.settings.get('mathjax_url', '')
131 135
132 136 @property
133 137 def base_url(self):
134 138 return self.settings.get('base_url', '/')
135 139
136 140 @property
137 141 def ws_url(self):
138 142 return self.settings.get('websocket_url', '')
139 143
140 144 @property
141 145 def contents_js_source(self):
142 146 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
143 147 'services/contents'))
144 148 return self.settings.get('contents_js_source', 'services/contents')
145 149
146 150 #---------------------------------------------------------------
147 151 # Manager objects
148 152 #---------------------------------------------------------------
149 153
150 154 @property
151 155 def kernel_manager(self):
152 156 return self.settings['kernel_manager']
153 157
154 158 @property
155 159 def contents_manager(self):
156 160 return self.settings['contents_manager']
157 161
158 162 @property
159 163 def cluster_manager(self):
160 164 return self.settings['cluster_manager']
161 165
162 166 @property
163 167 def session_manager(self):
164 168 return self.settings['session_manager']
165 169
166 170 @property
167 171 def terminal_manager(self):
168 172 return self.settings['terminal_manager']
169 173
170 174 @property
171 175 def kernel_spec_manager(self):
172 176 return self.settings['kernel_spec_manager']
173 177
174 178 #---------------------------------------------------------------
175 179 # CORS
176 180 #---------------------------------------------------------------
177 181
178 182 @property
179 183 def allow_origin(self):
180 184 """Normal Access-Control-Allow-Origin"""
181 185 return self.settings.get('allow_origin', '')
182 186
183 187 @property
184 188 def allow_origin_pat(self):
185 189 """Regular expression version of allow_origin"""
186 190 return self.settings.get('allow_origin_pat', None)
187 191
188 192 @property
189 193 def allow_credentials(self):
190 194 """Whether to set Access-Control-Allow-Credentials"""
191 195 return self.settings.get('allow_credentials', False)
192 196
193 197 def set_default_headers(self):
194 198 """Add CORS headers, if defined"""
195 199 super(IPythonHandler, self).set_default_headers()
196 200 if self.allow_origin:
197 201 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
198 202 elif self.allow_origin_pat:
199 203 origin = self.get_origin()
200 204 if origin and self.allow_origin_pat.match(origin):
201 205 self.set_header("Access-Control-Allow-Origin", origin)
202 206 if self.allow_credentials:
203 207 self.set_header("Access-Control-Allow-Credentials", 'true')
204 208
205 209 def get_origin(self):
206 210 # Handle WebSocket Origin naming convention differences
207 211 # The difference between version 8 and 13 is that in 8 the
208 212 # client sends a "Sec-Websocket-Origin" header and in 13 it's
209 213 # simply "Origin".
210 214 if "Origin" in self.request.headers:
211 215 origin = self.request.headers.get("Origin")
212 216 else:
213 217 origin = self.request.headers.get("Sec-Websocket-Origin", None)
214 218 return origin
215 219
216 220 #---------------------------------------------------------------
217 221 # template rendering
218 222 #---------------------------------------------------------------
219 223
220 224 def get_template(self, name):
221 225 """Return the jinja template object for a given name"""
222 226 return self.settings['jinja2_env'].get_template(name)
223 227
224 228 def render_template(self, name, **ns):
225 229 ns.update(self.template_namespace)
226 230 template = self.get_template(name)
227 231 return template.render(**ns)
228 232
229 233 @property
230 234 def template_namespace(self):
231 235 return dict(
232 236 base_url=self.base_url,
233 237 ws_url=self.ws_url,
234 238 logged_in=self.logged_in,
235 239 login_available=self.login_available,
236 240 static_url=self.static_url,
237 241 sys_info=sys_info,
238 242 contents_js_source=self.contents_js_source,
239 243 )
240 244
241 245 def get_json_body(self):
242 246 """Return the body of the request as JSON data."""
243 247 if not self.request.body:
244 248 return None
245 249 # Do we need to call body.decode('utf-8') here?
246 250 body = self.request.body.strip().decode(u'utf-8')
247 251 try:
248 252 model = json.loads(body)
249 253 except Exception:
250 254 self.log.debug("Bad JSON: %r", body)
251 255 self.log.error("Couldn't parse JSON", exc_info=True)
252 256 raise web.HTTPError(400, u'Invalid JSON in body of request')
253 257 return model
254 258
255 259 def write_error(self, status_code, **kwargs):
256 260 """render custom error pages"""
257 261 exc_info = kwargs.get('exc_info')
258 262 message = ''
259 263 status_message = responses.get(status_code, 'Unknown HTTP Error')
260 264 if exc_info:
261 265 exception = exc_info[1]
262 266 # get the custom message, if defined
263 267 try:
264 268 message = exception.log_message % exception.args
265 269 except Exception:
266 270 pass
267 271
268 272 # construct the custom reason, if defined
269 273 reason = getattr(exception, 'reason', '')
270 274 if reason:
271 275 status_message = reason
272 276
273 277 # build template namespace
274 278 ns = dict(
275 279 status_code=status_code,
276 280 status_message=status_message,
277 281 message=message,
278 282 exception=exception,
279 283 )
280 284
281 285 self.set_header('Content-Type', 'text/html')
282 286 # render the template
283 287 try:
284 288 html = self.render_template('%s.html' % status_code, **ns)
285 289 except TemplateNotFound:
286 290 self.log.debug("No template for %d", status_code)
287 291 html = self.render_template('error.html', **ns)
288 292
289 293 self.write(html)
290 294
291 295
292 296
293 297 class Template404(IPythonHandler):
294 298 """Render our 404 template"""
295 299 def prepare(self):
296 300 raise web.HTTPError(404)
297 301
298 302
299 303 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
300 304 """static files should only be accessible when logged in"""
301 305
302 306 @web.authenticated
303 307 def get(self, path):
304 308 if os.path.splitext(path)[1] == '.ipynb':
305 309 name = path.rsplit('/', 1)[-1]
306 310 self.set_header('Content-Type', 'application/json')
307 311 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
308 312
309 313 return web.StaticFileHandler.get(self, path)
310 314
311 315 def compute_etag(self):
312 316 return None
313 317
314 318 def validate_absolute_path(self, root, absolute_path):
315 319 """Validate and return the absolute path.
316 320
317 321 Requires tornado 3.1
318 322
319 323 Adding to tornado's own handling, forbids the serving of hidden files.
320 324 """
321 325 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
322 326 abs_root = os.path.abspath(root)
323 327 if is_hidden(abs_path, abs_root):
324 328 self.log.info("Refusing to serve hidden file, via 404 Error")
325 329 raise web.HTTPError(404)
326 330 return abs_path
327 331
328 332
329 333 def json_errors(method):
330 334 """Decorate methods with this to return GitHub style JSON errors.
331 335
332 336 This should be used on any JSON API on any handler method that can raise HTTPErrors.
333 337
334 338 This will grab the latest HTTPError exception using sys.exc_info
335 339 and then:
336 340
337 341 1. Set the HTTP status code based on the HTTPError
338 342 2. Create and return a JSON body with a message field describing
339 343 the error in a human readable form.
340 344 """
341 345 @functools.wraps(method)
342 346 def wrapper(self, *args, **kwargs):
343 347 try:
344 348 result = method(self, *args, **kwargs)
345 349 except web.HTTPError as e:
346 350 status = e.status_code
347 351 message = e.log_message
348 352 self.log.warn(message)
349 353 self.set_status(e.status_code)
350 354 self.finish(json.dumps(dict(message=message)))
351 355 except Exception:
352 356 self.log.error("Unhandled error in API request", exc_info=True)
353 357 status = 500
354 358 message = "Unknown server error"
355 359 t, value, tb = sys.exc_info()
356 360 self.set_status(status)
357 361 tb_text = ''.join(traceback.format_exception(t, value, tb))
358 362 reply = dict(message=message, traceback=tb_text)
359 363 self.finish(json.dumps(reply))
360 364 else:
361 365 return result
362 366 return wrapper
363 367
364 368
365 369
366 370 #-----------------------------------------------------------------------------
367 371 # File handler
368 372 #-----------------------------------------------------------------------------
369 373
370 374 # to minimize subclass changes:
371 375 HTTPError = web.HTTPError
372 376
373 377 class FileFindHandler(web.StaticFileHandler):
374 378 """subclass of StaticFileHandler for serving files from a search path"""
375 379
376 380 # cache search results, don't search for files more than once
377 381 _static_paths = {}
378 382
379 383 def initialize(self, path, default_filename=None):
380 384 if isinstance(path, string_types):
381 385 path = [path]
382 386
383 387 self.root = tuple(
384 388 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
385 389 )
386 390 self.default_filename = default_filename
387 391
388 392 def compute_etag(self):
389 393 return None
390 394
391 395 @classmethod
392 396 def get_absolute_path(cls, roots, path):
393 397 """locate a file to serve on our static file search path"""
394 398 with cls._lock:
395 399 if path in cls._static_paths:
396 400 return cls._static_paths[path]
397 401 try:
398 402 abspath = os.path.abspath(filefind(path, roots))
399 403 except IOError:
400 404 # IOError means not found
401 405 return ''
402 406
403 407 cls._static_paths[path] = abspath
404 408 return abspath
405 409
406 410 def validate_absolute_path(self, root, absolute_path):
407 411 """check if the file should be served (raises 404, 403, etc.)"""
408 412 if absolute_path == '':
409 413 raise web.HTTPError(404)
410 414
411 415 for root in self.root:
412 416 if (absolute_path + os.sep).startswith(root):
413 417 break
414 418
415 419 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
416 420
417 421
418 422 class ApiVersionHandler(IPythonHandler):
419 423
420 424 @json_errors
421 425 def get(self):
422 426 # not authenticated, so give as few info as possible
423 427 self.finish(json.dumps({"version":IPython.__version__}))
424 428
425 429
426 430 class TrailingSlashHandler(web.RequestHandler):
427 431 """Simple redirect handler that strips trailing slashes
428 432
429 433 This should be the first, highest priority handler.
430 434 """
431 435
432 436 def get(self):
433 437 self.redirect(self.request.uri.rstrip('/'))
434 438
435 439 post = put = get
436 440
437 441
438 442 class FilesRedirectHandler(IPythonHandler):
439 443 """Handler for redirecting relative URLs to the /files/ handler"""
440 444 def get(self, path=''):
441 445 cm = self.contents_manager
442 446 if cm.dir_exists(path):
443 447 # it's a *directory*, redirect to /tree
444 448 url = url_path_join(self.base_url, 'tree', path)
445 449 else:
446 450 orig_path = path
447 451 # otherwise, redirect to /files
448 452 parts = path.split('/')
449 453
450 454 if not cm.file_exists(path=path) and 'files' in parts:
451 455 # redirect without files/ iff it would 404
452 456 # this preserves pre-2.0-style 'files/' links
453 457 self.log.warn("Deprecated files/ URL: %s", orig_path)
454 458 parts.remove('files')
455 459 path = '/'.join(parts)
456 460
457 461 if not cm.file_exists(path=path):
458 462 raise web.HTTPError(404)
459 463
460 464 url = url_path_join(self.base_url, 'files', path)
461 465 url = url_escape(url)
462 466 self.log.debug("Redirecting %s to %s", self.request.path, url)
463 467 self.redirect(url)
464 468
465 469
466 470 #-----------------------------------------------------------------------------
467 471 # URL pattern fragments for re-use
468 472 #-----------------------------------------------------------------------------
469 473
470 474 # path matches any number of `/foo[/bar...]` or just `/` or ''
471 475 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
472 476 notebook_path_regex = r"(?P<path>(?:/[^/]+)+\.ipynb)"
473 477
474 478 #-----------------------------------------------------------------------------
475 479 # URL to handler mappings
476 480 #-----------------------------------------------------------------------------
477 481
478 482
479 483 default_handlers = [
480 484 (r".*/", TrailingSlashHandler),
481 485 (r"api", ApiVersionHandler)
482 486 ]
@@ -1,990 +1,992
1 1 # coding: utf-8
2 2 """A tornado based IPython notebook server."""
3 3
4 4 # Copyright (c) IPython Development Team.
5 5 # Distributed under the terms of the Modified BSD License.
6 6
7 7 from __future__ import print_function
8 8
9 9 import base64
10 10 import errno
11 11 import io
12 12 import json
13 13 import logging
14 14 import os
15 15 import random
16 16 import re
17 17 import select
18 18 import signal
19 19 import socket
20 20 import sys
21 21 import threading
22 22 import time
23 23 import webbrowser
24 24
25 25
26 26 # check for pyzmq 2.1.11
27 27 from IPython.utils.zmqrelated import check_for_zmq
28 28 check_for_zmq('2.1.11', 'IPython.html')
29 29
30 30 from jinja2 import Environment, FileSystemLoader
31 31
32 32 # Install the pyzmq ioloop. This has to be done before anything else from
33 33 # tornado is imported.
34 34 from zmq.eventloop import ioloop
35 35 ioloop.install()
36 36
37 37 # check for tornado 3.1.0
38 38 msg = "The IPython Notebook requires tornado >= 3.1.0"
39 39 try:
40 40 import tornado
41 41 except ImportError:
42 42 raise ImportError(msg)
43 43 try:
44 44 version_info = tornado.version_info
45 45 except AttributeError:
46 46 raise ImportError(msg + ", but you have < 1.1.0")
47 47 if version_info < (3,1,0):
48 48 raise ImportError(msg + ", but you have %s" % tornado.version)
49 49
50 50 from tornado import httpserver
51 51 from tornado import web
52 52 from tornado.log import LogFormatter, app_log, access_log, gen_log
53 53
54 54 from IPython.html import (
55 55 DEFAULT_STATIC_FILES_PATH,
56 56 DEFAULT_TEMPLATE_PATH_LIST,
57 57 )
58 58 from .base.handlers import Template404
59 59 from .log import log_request
60 60 from .services.kernels.kernelmanager import MappingKernelManager
61 61 from .services.contents.manager import ContentsManager
62 62 from .services.contents.filemanager import FileContentsManager
63 63 from .services.clusters.clustermanager import ClusterManager
64 64 from .services.sessions.sessionmanager import SessionManager
65 65
66 66 from .base.handlers import AuthenticatedFileHandler, FileFindHandler
67 67
68 68 from IPython.config import Config
69 69 from IPython.config.application import catch_config_error, boolean_flag
70 70 from IPython.core.application import (
71 71 BaseIPythonApplication, base_flags, base_aliases,
72 72 )
73 73 from IPython.core.profiledir import ProfileDir
74 74 from IPython.kernel import KernelManager
75 75 from IPython.kernel.kernelspec import KernelSpecManager
76 76 from IPython.kernel.zmq.session import default_secure, Session
77 77 from IPython.nbformat.sign import NotebookNotary
78 78 from IPython.utils.importstring import import_item
79 79 from IPython.utils import submodule
80 80 from IPython.utils.process import check_pid
81 81 from IPython.utils.traitlets import (
82 82 Dict, Unicode, Integer, List, Bool, Bytes, Instance,
83 83 DottedObjectName, TraitError,
84 84 )
85 85 from IPython.utils import py3compat
86 86 from IPython.utils.path import filefind, get_ipython_dir
87 87
88 88 from .utils import url_path_join
89 89
90 90 #-----------------------------------------------------------------------------
91 91 # Module globals
92 92 #-----------------------------------------------------------------------------
93 93
94 94 _examples = """
95 95 ipython notebook # start the notebook
96 96 ipython notebook --profile=sympy # use the sympy profile
97 97 ipython notebook --certfile=mycert.pem # use SSL/TLS certificate
98 98 """
99 99
100 100 #-----------------------------------------------------------------------------
101 101 # Helper functions
102 102 #-----------------------------------------------------------------------------
103 103
104 104 def random_ports(port, n):
105 105 """Generate a list of n random ports near the given port.
106 106
107 107 The first 5 ports will be sequential, and the remaining n-5 will be
108 108 randomly selected in the range [port-2*n, port+2*n].
109 109 """
110 110 for i in range(min(5, n)):
111 111 yield port + i
112 112 for i in range(n-5):
113 113 yield max(1, port + random.randint(-2*n, 2*n))
114 114
115 115 def load_handlers(name):
116 116 """Load the (URL pattern, handler) tuples for each component."""
117 117 name = 'IPython.html.' + name
118 118 mod = __import__(name, fromlist=['default_handlers'])
119 119 return mod.default_handlers
120 120
121 121 #-----------------------------------------------------------------------------
122 122 # The Tornado web application
123 123 #-----------------------------------------------------------------------------
124 124
125 125 class NotebookWebApplication(web.Application):
126 126
127 127 def __init__(self, ipython_app, kernel_manager, contents_manager,
128 128 cluster_manager, session_manager, kernel_spec_manager, log,
129 129 base_url, default_url, settings_overrides, jinja_env_options):
130 130
131 131 settings = self.init_settings(
132 132 ipython_app, kernel_manager, contents_manager, cluster_manager,
133 133 session_manager, kernel_spec_manager, log, base_url, default_url,
134 134 settings_overrides, jinja_env_options)
135 135 handlers = self.init_handlers(settings)
136 136
137 137 super(NotebookWebApplication, self).__init__(handlers, **settings)
138 138
139 139 def init_settings(self, ipython_app, kernel_manager, contents_manager,
140 140 cluster_manager, session_manager, kernel_spec_manager,
141 141 log, base_url, default_url, settings_overrides,
142 142 jinja_env_options=None):
143 143
144 144 _template_path = settings_overrides.get(
145 145 "template_path",
146 146 ipython_app.template_file_path,
147 147 )
148 148 if isinstance(_template_path, str):
149 149 _template_path = (_template_path,)
150 150 template_path = [os.path.expanduser(path) for path in _template_path]
151 151
152 152 jenv_opt = jinja_env_options if jinja_env_options else {}
153 153 env = Environment(loader=FileSystemLoader(template_path), **jenv_opt)
154 154 settings = dict(
155 155 # basics
156 156 log_function=log_request,
157 157 base_url=base_url,
158 158 default_url=default_url,
159 159 template_path=template_path,
160 160 static_path=ipython_app.static_file_path,
161 161 static_handler_class = FileFindHandler,
162 162 static_url_prefix = url_path_join(base_url,'/static/'),
163 163
164 164 # authentication
165 165 cookie_secret=ipython_app.cookie_secret,
166 166 login_url=url_path_join(base_url,'/login'),
167 167 password=ipython_app.password,
168 168
169 169 # managers
170 170 kernel_manager=kernel_manager,
171 171 contents_manager=contents_manager,
172 172 cluster_manager=cluster_manager,
173 173 session_manager=session_manager,
174 174 kernel_spec_manager=kernel_spec_manager,
175 175
176 176 # IPython stuff
177 177 nbextensions_path = ipython_app.nbextensions_path,
178 178 websocket_url=ipython_app.websocket_url,
179 179 mathjax_url=ipython_app.mathjax_url,
180 180 config=ipython_app.config,
181 181 jinja2_env=env,
182 182 terminals_available=False, # Set later if terminals are available
183 profile_dir = ipython_app.profile_dir.location,
183 184 )
184 185
185 186 # allow custom overrides for the tornado web app.
186 187 settings.update(settings_overrides)
187 188 return settings
188 189
189 190 def init_handlers(self, settings):
190 191 """Load the (URL pattern, handler) tuples for each component."""
191 192
192 193 # Order matters. The first handler to match the URL will handle the request.
193 194 handlers = []
194 195 handlers.extend(load_handlers('tree.handlers'))
195 196 handlers.extend(load_handlers('auth.login'))
196 197 handlers.extend(load_handlers('auth.logout'))
197 198 handlers.extend(load_handlers('files.handlers'))
198 199 handlers.extend(load_handlers('notebook.handlers'))
199 200 handlers.extend(load_handlers('nbconvert.handlers'))
200 201 handlers.extend(load_handlers('kernelspecs.handlers'))
202 handlers.extend(load_handlers('services.config.handlers'))
201 203 handlers.extend(load_handlers('services.kernels.handlers'))
202 204 handlers.extend(load_handlers('services.contents.handlers'))
203 205 handlers.extend(load_handlers('services.clusters.handlers'))
204 206 handlers.extend(load_handlers('services.sessions.handlers'))
205 207 handlers.extend(load_handlers('services.nbconvert.handlers'))
206 208 handlers.extend(load_handlers('services.kernelspecs.handlers'))
207 209 handlers.append(
208 210 (r"/nbextensions/(.*)", FileFindHandler, {'path' : settings['nbextensions_path']}),
209 211 )
210 212 # register base handlers last
211 213 handlers.extend(load_handlers('base.handlers'))
212 214 # set the URL that will be redirected from `/`
213 215 handlers.append(
214 216 (r'/?', web.RedirectHandler, {
215 217 'url' : url_path_join(settings['base_url'], settings['default_url']),
216 218 'permanent': False, # want 302, not 301
217 219 })
218 220 )
219 221 # prepend base_url onto the patterns that we match
220 222 new_handlers = []
221 223 for handler in handlers:
222 224 pattern = url_path_join(settings['base_url'], handler[0])
223 225 new_handler = tuple([pattern] + list(handler[1:]))
224 226 new_handlers.append(new_handler)
225 227 # add 404 on the end, which will catch everything that falls through
226 228 new_handlers.append((r'(.*)', Template404))
227 229 return new_handlers
228 230
229 231
230 232 class NbserverListApp(BaseIPythonApplication):
231 233
232 234 description="List currently running notebook servers in this profile."
233 235
234 236 flags = dict(
235 237 json=({'NbserverListApp': {'json': True}},
236 238 "Produce machine-readable JSON output."),
237 239 )
238 240
239 241 json = Bool(False, config=True,
240 242 help="If True, each line of output will be a JSON object with the "
241 243 "details from the server info file.")
242 244
243 245 def start(self):
244 246 if not self.json:
245 247 print("Currently running servers:")
246 248 for serverinfo in list_running_servers(self.profile):
247 249 if self.json:
248 250 print(json.dumps(serverinfo))
249 251 else:
250 252 print(serverinfo['url'], "::", serverinfo['notebook_dir'])
251 253
252 254 #-----------------------------------------------------------------------------
253 255 # Aliases and Flags
254 256 #-----------------------------------------------------------------------------
255 257
256 258 flags = dict(base_flags)
257 259 flags['no-browser']=(
258 260 {'NotebookApp' : {'open_browser' : False}},
259 261 "Don't open the notebook in a browser after startup."
260 262 )
261 263 flags['pylab']=(
262 264 {'NotebookApp' : {'pylab' : 'warn'}},
263 265 "DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib."
264 266 )
265 267 flags['no-mathjax']=(
266 268 {'NotebookApp' : {'enable_mathjax' : False}},
267 269 """Disable MathJax
268 270
269 271 MathJax is the javascript library IPython uses to render math/LaTeX. It is
270 272 very large, so you may want to disable it if you have a slow internet
271 273 connection, or for offline use of the notebook.
272 274
273 275 When disabled, equations etc. will appear as their untransformed TeX source.
274 276 """
275 277 )
276 278
277 279 # Add notebook manager flags
278 280 flags.update(boolean_flag('script', 'FileContentsManager.save_script',
279 281 'DEPRECATED, IGNORED',
280 282 'DEPRECATED, IGNORED'))
281 283
282 284 aliases = dict(base_aliases)
283 285
284 286 aliases.update({
285 287 'ip': 'NotebookApp.ip',
286 288 'port': 'NotebookApp.port',
287 289 'port-retries': 'NotebookApp.port_retries',
288 290 'transport': 'KernelManager.transport',
289 291 'keyfile': 'NotebookApp.keyfile',
290 292 'certfile': 'NotebookApp.certfile',
291 293 'notebook-dir': 'NotebookApp.notebook_dir',
292 294 'browser': 'NotebookApp.browser',
293 295 'pylab': 'NotebookApp.pylab',
294 296 })
295 297
296 298 #-----------------------------------------------------------------------------
297 299 # NotebookApp
298 300 #-----------------------------------------------------------------------------
299 301
300 302 class NotebookApp(BaseIPythonApplication):
301 303
302 304 name = 'ipython-notebook'
303 305
304 306 description = """
305 307 The IPython HTML Notebook.
306 308
307 309 This launches a Tornado based HTML Notebook Server that serves up an
308 310 HTML5/Javascript Notebook client.
309 311 """
310 312 examples = _examples
311 313 aliases = aliases
312 314 flags = flags
313 315
314 316 classes = [
315 317 KernelManager, ProfileDir, Session, MappingKernelManager,
316 318 ContentsManager, FileContentsManager, NotebookNotary,
317 319 ]
318 320 flags = Dict(flags)
319 321 aliases = Dict(aliases)
320 322
321 323 subcommands = dict(
322 324 list=(NbserverListApp, NbserverListApp.description.splitlines()[0]),
323 325 )
324 326
325 327 kernel_argv = List(Unicode)
326 328
327 329 _log_formatter_cls = LogFormatter
328 330
329 331 def _log_level_default(self):
330 332 return logging.INFO
331 333
332 334 def _log_datefmt_default(self):
333 335 """Exclude date from default date format"""
334 336 return "%H:%M:%S"
335 337
336 338 def _log_format_default(self):
337 339 """override default log format to include time"""
338 340 return u"%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s"
339 341
340 342 # create requested profiles by default, if they don't exist:
341 343 auto_create = Bool(True)
342 344
343 345 # file to be opened in the notebook server
344 346 file_to_run = Unicode('', config=True)
345 347 def _file_to_run_changed(self, name, old, new):
346 348 path, base = os.path.split(new)
347 349 if path:
348 350 self.file_to_run = base
349 351 self.notebook_dir = path
350 352
351 353 # Network related information
352 354
353 355 allow_origin = Unicode('', config=True,
354 356 help="""Set the Access-Control-Allow-Origin header
355 357
356 358 Use '*' to allow any origin to access your server.
357 359
358 360 Takes precedence over allow_origin_pat.
359 361 """
360 362 )
361 363
362 364 allow_origin_pat = Unicode('', config=True,
363 365 help="""Use a regular expression for the Access-Control-Allow-Origin header
364 366
365 367 Requests from an origin matching the expression will get replies with:
366 368
367 369 Access-Control-Allow-Origin: origin
368 370
369 371 where `origin` is the origin of the request.
370 372
371 373 Ignored if allow_origin is set.
372 374 """
373 375 )
374 376
375 377 allow_credentials = Bool(False, config=True,
376 378 help="Set the Access-Control-Allow-Credentials: true header"
377 379 )
378 380
379 381 default_url = Unicode('/tree', config=True,
380 382 help="The default URL to redirect to from `/`"
381 383 )
382 384
383 385 ip = Unicode('localhost', config=True,
384 386 help="The IP address the notebook server will listen on."
385 387 )
386 388
387 389 def _ip_changed(self, name, old, new):
388 390 if new == u'*': self.ip = u''
389 391
390 392 port = Integer(8888, config=True,
391 393 help="The port the notebook server will listen on."
392 394 )
393 395 port_retries = Integer(50, config=True,
394 396 help="The number of additional ports to try if the specified port is not available."
395 397 )
396 398
397 399 certfile = Unicode(u'', config=True,
398 400 help="""The full path to an SSL/TLS certificate file."""
399 401 )
400 402
401 403 keyfile = Unicode(u'', config=True,
402 404 help="""The full path to a private key file for usage with SSL/TLS."""
403 405 )
404 406
405 407 cookie_secret_file = Unicode(config=True,
406 408 help="""The file where the cookie secret is stored."""
407 409 )
408 410 def _cookie_secret_file_default(self):
409 411 if self.profile_dir is None:
410 412 return ''
411 413 return os.path.join(self.profile_dir.security_dir, 'notebook_cookie_secret')
412 414
413 415 cookie_secret = Bytes(b'', config=True,
414 416 help="""The random bytes used to secure cookies.
415 417 By default this is a new random number every time you start the Notebook.
416 418 Set it to a value in a config file to enable logins to persist across server sessions.
417 419
418 420 Note: Cookie secrets should be kept private, do not share config files with
419 421 cookie_secret stored in plaintext (you can read the value from a file).
420 422 """
421 423 )
422 424 def _cookie_secret_default(self):
423 425 if os.path.exists(self.cookie_secret_file):
424 426 with io.open(self.cookie_secret_file, 'rb') as f:
425 427 return f.read()
426 428 else:
427 429 secret = base64.encodestring(os.urandom(1024))
428 430 self._write_cookie_secret_file(secret)
429 431 return secret
430 432
431 433 def _write_cookie_secret_file(self, secret):
432 434 """write my secret to my secret_file"""
433 435 self.log.info("Writing notebook server cookie secret to %s", self.cookie_secret_file)
434 436 with io.open(self.cookie_secret_file, 'wb') as f:
435 437 f.write(secret)
436 438 try:
437 439 os.chmod(self.cookie_secret_file, 0o600)
438 440 except OSError:
439 441 self.log.warn(
440 442 "Could not set permissions on %s",
441 443 self.cookie_secret_file
442 444 )
443 445
444 446 password = Unicode(u'', config=True,
445 447 help="""Hashed password to use for web authentication.
446 448
447 449 To generate, type in a python/IPython shell:
448 450
449 451 from IPython.lib import passwd; passwd()
450 452
451 453 The string should be of the form type:salt:hashed-password.
452 454 """
453 455 )
454 456
455 457 open_browser = Bool(True, config=True,
456 458 help="""Whether to open in a browser after starting.
457 459 The specific browser used is platform dependent and
458 460 determined by the python standard library `webbrowser`
459 461 module, unless it is overridden using the --browser
460 462 (NotebookApp.browser) configuration option.
461 463 """)
462 464
463 465 browser = Unicode(u'', config=True,
464 466 help="""Specify what command to use to invoke a web
465 467 browser when opening the notebook. If not specified, the
466 468 default browser will be determined by the `webbrowser`
467 469 standard library module, which allows setting of the
468 470 BROWSER environment variable to override it.
469 471 """)
470 472
471 473 webapp_settings = Dict(config=True,
472 474 help="DEPRECATED, use tornado_settings"
473 475 )
474 476 def _webapp_settings_changed(self, name, old, new):
475 477 self.log.warn("\n webapp_settings is deprecated, use tornado_settings.\n")
476 478 self.tornado_settings = new
477 479
478 480 tornado_settings = Dict(config=True,
479 481 help="Supply overrides for the tornado.web.Application that the "
480 482 "IPython notebook uses.")
481 483
482 484 jinja_environment_options = Dict(config=True,
483 485 help="Supply extra arguments that will be passed to Jinja environment.")
484 486
485 487
486 488 enable_mathjax = Bool(True, config=True,
487 489 help="""Whether to enable MathJax for typesetting math/TeX
488 490
489 491 MathJax is the javascript library IPython uses to render math/LaTeX. It is
490 492 very large, so you may want to disable it if you have a slow internet
491 493 connection, or for offline use of the notebook.
492 494
493 495 When disabled, equations etc. will appear as their untransformed TeX source.
494 496 """
495 497 )
496 498 def _enable_mathjax_changed(self, name, old, new):
497 499 """set mathjax url to empty if mathjax is disabled"""
498 500 if not new:
499 501 self.mathjax_url = u''
500 502
501 503 base_url = Unicode('/', config=True,
502 504 help='''The base URL for the notebook server.
503 505
504 506 Leading and trailing slashes can be omitted,
505 507 and will automatically be added.
506 508 ''')
507 509 def _base_url_changed(self, name, old, new):
508 510 if not new.startswith('/'):
509 511 self.base_url = '/'+new
510 512 elif not new.endswith('/'):
511 513 self.base_url = new+'/'
512 514
513 515 base_project_url = Unicode('/', config=True, help="""DEPRECATED use base_url""")
514 516 def _base_project_url_changed(self, name, old, new):
515 517 self.log.warn("base_project_url is deprecated, use base_url")
516 518 self.base_url = new
517 519
518 520 extra_static_paths = List(Unicode, config=True,
519 521 help="""Extra paths to search for serving static files.
520 522
521 523 This allows adding javascript/css to be available from the notebook server machine,
522 524 or overriding individual files in the IPython"""
523 525 )
524 526 def _extra_static_paths_default(self):
525 527 return [os.path.join(self.profile_dir.location, 'static')]
526 528
527 529 @property
528 530 def static_file_path(self):
529 531 """return extra paths + the default location"""
530 532 return self.extra_static_paths + [DEFAULT_STATIC_FILES_PATH]
531 533
532 534 extra_template_paths = List(Unicode, config=True,
533 535 help="""Extra paths to search for serving jinja templates.
534 536
535 537 Can be used to override templates from IPython.html.templates."""
536 538 )
537 539 def _extra_template_paths_default(self):
538 540 return []
539 541
540 542 @property
541 543 def template_file_path(self):
542 544 """return extra paths + the default locations"""
543 545 return self.extra_template_paths + DEFAULT_TEMPLATE_PATH_LIST
544 546
545 547 nbextensions_path = List(Unicode, config=True,
546 548 help="""paths for Javascript extensions. By default, this is just IPYTHONDIR/nbextensions"""
547 549 )
548 550 def _nbextensions_path_default(self):
549 551 return [os.path.join(get_ipython_dir(), 'nbextensions')]
550 552
551 553 websocket_url = Unicode("", config=True,
552 554 help="""The base URL for websockets,
553 555 if it differs from the HTTP server (hint: it almost certainly doesn't).
554 556
555 557 Should be in the form of an HTTP origin: ws[s]://hostname[:port]
556 558 """
557 559 )
558 560 mathjax_url = Unicode("", config=True,
559 561 help="""The url for MathJax.js."""
560 562 )
561 563 def _mathjax_url_default(self):
562 564 if not self.enable_mathjax:
563 565 return u''
564 566 static_url_prefix = self.tornado_settings.get("static_url_prefix",
565 567 url_path_join(self.base_url, "static")
566 568 )
567 569
568 570 # try local mathjax, either in nbextensions/mathjax or static/mathjax
569 571 for (url_prefix, search_path) in [
570 572 (url_path_join(self.base_url, "nbextensions"), self.nbextensions_path),
571 573 (static_url_prefix, self.static_file_path),
572 574 ]:
573 575 self.log.debug("searching for local mathjax in %s", search_path)
574 576 try:
575 577 mathjax = filefind(os.path.join('mathjax', 'MathJax.js'), search_path)
576 578 except IOError:
577 579 continue
578 580 else:
579 581 url = url_path_join(url_prefix, u"mathjax/MathJax.js")
580 582 self.log.info("Serving local MathJax from %s at %s", mathjax, url)
581 583 return url
582 584
583 585 # no local mathjax, serve from CDN
584 586 url = u"https://cdn.mathjax.org/mathjax/latest/MathJax.js"
585 587 self.log.info("Using MathJax from CDN: %s", url)
586 588 return url
587 589
588 590 def _mathjax_url_changed(self, name, old, new):
589 591 if new and not self.enable_mathjax:
590 592 # enable_mathjax=False overrides mathjax_url
591 593 self.mathjax_url = u''
592 594 else:
593 595 self.log.info("Using MathJax: %s", new)
594 596
595 597 contents_manager_class = DottedObjectName('IPython.html.services.contents.filemanager.FileContentsManager',
596 598 config=True,
597 599 help='The notebook manager class to use.'
598 600 )
599 601 kernel_manager_class = DottedObjectName('IPython.html.services.kernels.kernelmanager.MappingKernelManager',
600 602 config=True,
601 603 help='The kernel manager class to use.'
602 604 )
603 605 session_manager_class = DottedObjectName('IPython.html.services.sessions.sessionmanager.SessionManager',
604 606 config=True,
605 607 help='The session manager class to use.'
606 608 )
607 609 cluster_manager_class = DottedObjectName('IPython.html.services.clusters.clustermanager.ClusterManager',
608 610 config=True,
609 611 help='The cluster manager class to use.'
610 612 )
611 613
612 614 kernel_spec_manager = Instance(KernelSpecManager)
613 615
614 616 def _kernel_spec_manager_default(self):
615 617 return KernelSpecManager(ipython_dir=self.ipython_dir)
616 618
617 619 trust_xheaders = Bool(False, config=True,
618 620 help=("Whether to trust or not X-Scheme/X-Forwarded-Proto and X-Real-Ip/X-Forwarded-For headers"
619 621 "sent by the upstream reverse proxy. Necessary if the proxy handles SSL")
620 622 )
621 623
622 624 info_file = Unicode()
623 625
624 626 def _info_file_default(self):
625 627 info_file = "nbserver-%s.json"%os.getpid()
626 628 return os.path.join(self.profile_dir.security_dir, info_file)
627 629
628 630 notebook_dir = Unicode(py3compat.getcwd(), config=True,
629 631 help="The directory to use for notebooks and kernels."
630 632 )
631 633
632 634 pylab = Unicode('disabled', config=True,
633 635 help="""
634 636 DISABLED: use %pylab or %matplotlib in the notebook to enable matplotlib.
635 637 """
636 638 )
637 639 def _pylab_changed(self, name, old, new):
638 640 """when --pylab is specified, display a warning and exit"""
639 641 if new != 'warn':
640 642 backend = ' %s' % new
641 643 else:
642 644 backend = ''
643 645 self.log.error("Support for specifying --pylab on the command line has been removed.")
644 646 self.log.error(
645 647 "Please use `%pylab{0}` or `%matplotlib{0}` in the notebook itself.".format(backend)
646 648 )
647 649 self.exit(1)
648 650
649 651 def _notebook_dir_changed(self, name, old, new):
650 652 """Do a bit of validation of the notebook dir."""
651 653 if not os.path.isabs(new):
652 654 # If we receive a non-absolute path, make it absolute.
653 655 self.notebook_dir = os.path.abspath(new)
654 656 return
655 657 if not os.path.isdir(new):
656 658 raise TraitError("No such notebook dir: %r" % new)
657 659
658 660 # setting App.notebook_dir implies setting notebook and kernel dirs as well
659 661 self.config.FileContentsManager.root_dir = new
660 662 self.config.MappingKernelManager.root_dir = new
661 663
662 664
663 665 def parse_command_line(self, argv=None):
664 666 super(NotebookApp, self).parse_command_line(argv)
665 667
666 668 if self.extra_args:
667 669 arg0 = self.extra_args[0]
668 670 f = os.path.abspath(arg0)
669 671 self.argv.remove(arg0)
670 672 if not os.path.exists(f):
671 673 self.log.critical("No such file or directory: %s", f)
672 674 self.exit(1)
673 675
674 676 # Use config here, to ensure that it takes higher priority than
675 677 # anything that comes from the profile.
676 678 c = Config()
677 679 if os.path.isdir(f):
678 680 c.NotebookApp.notebook_dir = f
679 681 elif os.path.isfile(f):
680 682 c.NotebookApp.file_to_run = f
681 683 self.update_config(c)
682 684
683 685 def init_kernel_argv(self):
684 686 """construct the kernel arguments"""
685 687 # Kernel should get *absolute* path to profile directory
686 688 self.kernel_argv = ["--profile-dir", self.profile_dir.location]
687 689
688 690 def init_configurables(self):
689 691 # force Session default to be secure
690 692 default_secure(self.config)
691 693 kls = import_item(self.kernel_manager_class)
692 694 self.kernel_manager = kls(
693 695 parent=self, log=self.log, kernel_argv=self.kernel_argv,
694 696 connection_dir = self.profile_dir.security_dir,
695 697 )
696 698 kls = import_item(self.contents_manager_class)
697 699 self.contents_manager = kls(parent=self, log=self.log)
698 700 kls = import_item(self.session_manager_class)
699 701 self.session_manager = kls(parent=self, log=self.log,
700 702 kernel_manager=self.kernel_manager,
701 703 contents_manager=self.contents_manager)
702 704 kls = import_item(self.cluster_manager_class)
703 705 self.cluster_manager = kls(parent=self, log=self.log)
704 706 self.cluster_manager.update_profiles()
705 707
706 708 def init_logging(self):
707 709 # This prevents double log messages because tornado use a root logger that
708 710 # self.log is a child of. The logging module dipatches log messages to a log
709 711 # and all of its ancenstors until propagate is set to False.
710 712 self.log.propagate = False
711 713
712 714 for log in app_log, access_log, gen_log:
713 715 # consistent log output name (NotebookApp instead of tornado.access, etc.)
714 716 log.name = self.log.name
715 717 # hook up tornado 3's loggers to our app handlers
716 718 logger = logging.getLogger('tornado')
717 719 logger.propagate = True
718 720 logger.parent = self.log
719 721 logger.setLevel(self.log.level)
720 722
721 723 def init_webapp(self):
722 724 """initialize tornado webapp and httpserver"""
723 725 self.tornado_settings['allow_origin'] = self.allow_origin
724 726 if self.allow_origin_pat:
725 727 self.tornado_settings['allow_origin_pat'] = re.compile(self.allow_origin_pat)
726 728 self.tornado_settings['allow_credentials'] = self.allow_credentials
727 729
728 730 self.web_app = NotebookWebApplication(
729 731 self, self.kernel_manager, self.contents_manager,
730 732 self.cluster_manager, self.session_manager, self.kernel_spec_manager,
731 733 self.log, self.base_url, self.default_url, self.tornado_settings,
732 734 self.jinja_environment_options
733 735 )
734 736 if self.certfile:
735 737 ssl_options = dict(certfile=self.certfile)
736 738 if self.keyfile:
737 739 ssl_options['keyfile'] = self.keyfile
738 740 else:
739 741 ssl_options = None
740 742 self.web_app.password = self.password
741 743 self.http_server = httpserver.HTTPServer(self.web_app, ssl_options=ssl_options,
742 744 xheaders=self.trust_xheaders)
743 745 if not self.ip:
744 746 warning = "WARNING: The notebook server is listening on all IP addresses"
745 747 if ssl_options is None:
746 748 self.log.critical(warning + " and not using encryption. This "
747 749 "is not recommended.")
748 750 if not self.password:
749 751 self.log.critical(warning + " and not using authentication. "
750 752 "This is highly insecure and not recommended.")
751 753 success = None
752 754 for port in random_ports(self.port, self.port_retries+1):
753 755 try:
754 756 self.http_server.listen(port, self.ip)
755 757 except socket.error as e:
756 758 if e.errno == errno.EADDRINUSE:
757 759 self.log.info('The port %i is already in use, trying another random port.' % port)
758 760 continue
759 761 elif e.errno in (errno.EACCES, getattr(errno, 'WSAEACCES', errno.EACCES)):
760 762 self.log.warn("Permission to listen on port %i denied" % port)
761 763 continue
762 764 else:
763 765 raise
764 766 else:
765 767 self.port = port
766 768 success = True
767 769 break
768 770 if not success:
769 771 self.log.critical('ERROR: the notebook server could not be started because '
770 772 'no available port could be found.')
771 773 self.exit(1)
772 774
773 775 @property
774 776 def display_url(self):
775 777 ip = self.ip if self.ip else '[all ip addresses on your system]'
776 778 return self._url(ip)
777 779
778 780 @property
779 781 def connection_url(self):
780 782 ip = self.ip if self.ip else 'localhost'
781 783 return self._url(ip)
782 784
783 785 def _url(self, ip):
784 786 proto = 'https' if self.certfile else 'http'
785 787 return "%s://%s:%i%s" % (proto, ip, self.port, self.base_url)
786 788
787 789 def init_terminals(self):
788 790 try:
789 791 from .terminal import initialize
790 792 initialize(self.web_app)
791 793 self.web_app.settings['terminals_available'] = True
792 794 except ImportError as e:
793 795 self.log.info("Terminals not available (error was %s)", e)
794 796
795 797 def init_signal(self):
796 798 if not sys.platform.startswith('win'):
797 799 signal.signal(signal.SIGINT, self._handle_sigint)
798 800 signal.signal(signal.SIGTERM, self._signal_stop)
799 801 if hasattr(signal, 'SIGUSR1'):
800 802 # Windows doesn't support SIGUSR1
801 803 signal.signal(signal.SIGUSR1, self._signal_info)
802 804 if hasattr(signal, 'SIGINFO'):
803 805 # only on BSD-based systems
804 806 signal.signal(signal.SIGINFO, self._signal_info)
805 807
806 808 def _handle_sigint(self, sig, frame):
807 809 """SIGINT handler spawns confirmation dialog"""
808 810 # register more forceful signal handler for ^C^C case
809 811 signal.signal(signal.SIGINT, self._signal_stop)
810 812 # request confirmation dialog in bg thread, to avoid
811 813 # blocking the App
812 814 thread = threading.Thread(target=self._confirm_exit)
813 815 thread.daemon = True
814 816 thread.start()
815 817
816 818 def _restore_sigint_handler(self):
817 819 """callback for restoring original SIGINT handler"""
818 820 signal.signal(signal.SIGINT, self._handle_sigint)
819 821
820 822 def _confirm_exit(self):
821 823 """confirm shutdown on ^C
822 824
823 825 A second ^C, or answering 'y' within 5s will cause shutdown,
824 826 otherwise original SIGINT handler will be restored.
825 827
826 828 This doesn't work on Windows.
827 829 """
828 830 info = self.log.info
829 831 info('interrupted')
830 832 print(self.notebook_info())
831 833 sys.stdout.write("Shutdown this notebook server (y/[n])? ")
832 834 sys.stdout.flush()
833 835 r,w,x = select.select([sys.stdin], [], [], 5)
834 836 if r:
835 837 line = sys.stdin.readline()
836 838 if line.lower().startswith('y') and 'n' not in line.lower():
837 839 self.log.critical("Shutdown confirmed")
838 840 ioloop.IOLoop.instance().stop()
839 841 return
840 842 else:
841 843 print("No answer for 5s:", end=' ')
842 844 print("resuming operation...")
843 845 # no answer, or answer is no:
844 846 # set it back to original SIGINT handler
845 847 # use IOLoop.add_callback because signal.signal must be called
846 848 # from main thread
847 849 ioloop.IOLoop.instance().add_callback(self._restore_sigint_handler)
848 850
849 851 def _signal_stop(self, sig, frame):
850 852 self.log.critical("received signal %s, stopping", sig)
851 853 ioloop.IOLoop.instance().stop()
852 854
853 855 def _signal_info(self, sig, frame):
854 856 print(self.notebook_info())
855 857
856 858 def init_components(self):
857 859 """Check the components submodule, and warn if it's unclean"""
858 860 status = submodule.check_submodule_status()
859 861 if status == 'missing':
860 862 self.log.warn("components submodule missing, running `git submodule update`")
861 863 submodule.update_submodules(submodule.ipython_parent())
862 864 elif status == 'unclean':
863 865 self.log.warn("components submodule unclean, you may see 404s on static/components")
864 866 self.log.warn("run `setup.py submodule` or `git submodule update` to update")
865 867
866 868 @catch_config_error
867 869 def initialize(self, argv=None):
868 870 super(NotebookApp, self).initialize(argv)
869 871 self.init_logging()
870 872 self.init_kernel_argv()
871 873 self.init_configurables()
872 874 self.init_components()
873 875 self.init_webapp()
874 876 self.init_terminals()
875 877 self.init_signal()
876 878
877 879 def cleanup_kernels(self):
878 880 """Shutdown all kernels.
879 881
880 882 The kernels will shutdown themselves when this process no longer exists,
881 883 but explicit shutdown allows the KernelManagers to cleanup the connection files.
882 884 """
883 885 self.log.info('Shutting down kernels')
884 886 self.kernel_manager.shutdown_all()
885 887
886 888 def notebook_info(self):
887 889 "Return the current working directory and the server url information"
888 890 info = self.contents_manager.info_string() + "\n"
889 891 info += "%d active kernels \n" % len(self.kernel_manager._kernels)
890 892 return info + "The IPython Notebook is running at: %s" % self.display_url
891 893
892 894 def server_info(self):
893 895 """Return a JSONable dict of information about this server."""
894 896 return {'url': self.connection_url,
895 897 'hostname': self.ip if self.ip else 'localhost',
896 898 'port': self.port,
897 899 'secure': bool(self.certfile),
898 900 'base_url': self.base_url,
899 901 'notebook_dir': os.path.abspath(self.notebook_dir),
900 902 'pid': os.getpid()
901 903 }
902 904
903 905 def write_server_info_file(self):
904 906 """Write the result of server_info() to the JSON file info_file."""
905 907 with open(self.info_file, 'w') as f:
906 908 json.dump(self.server_info(), f, indent=2)
907 909
908 910 def remove_server_info_file(self):
909 911 """Remove the nbserver-<pid>.json file created for this server.
910 912
911 913 Ignores the error raised when the file has already been removed.
912 914 """
913 915 try:
914 916 os.unlink(self.info_file)
915 917 except OSError as e:
916 918 if e.errno != errno.ENOENT:
917 919 raise
918 920
919 921 def start(self):
920 922 """ Start the IPython Notebook server app, after initialization
921 923
922 924 This method takes no arguments so all configuration and initialization
923 925 must be done prior to calling this method."""
924 926 if self.subapp is not None:
925 927 return self.subapp.start()
926 928
927 929 info = self.log.info
928 930 for line in self.notebook_info().split("\n"):
929 931 info(line)
930 932 info("Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).")
931 933
932 934 self.write_server_info_file()
933 935
934 936 if self.open_browser or self.file_to_run:
935 937 try:
936 938 browser = webbrowser.get(self.browser or None)
937 939 except webbrowser.Error as e:
938 940 self.log.warn('No web browser found: %s.' % e)
939 941 browser = None
940 942
941 943 if self.file_to_run:
942 944 fullpath = os.path.join(self.notebook_dir, self.file_to_run)
943 945 if not os.path.exists(fullpath):
944 946 self.log.critical("%s does not exist" % fullpath)
945 947 self.exit(1)
946 948
947 949 uri = url_path_join('notebooks', self.file_to_run)
948 950 else:
949 951 uri = 'tree'
950 952 if browser:
951 953 b = lambda : browser.open(url_path_join(self.connection_url, uri),
952 954 new=2)
953 955 threading.Thread(target=b).start()
954 956 try:
955 957 ioloop.IOLoop.instance().start()
956 958 except KeyboardInterrupt:
957 959 info("Interrupted...")
958 960 finally:
959 961 self.cleanup_kernels()
960 962 self.remove_server_info_file()
961 963
962 964
963 965 def list_running_servers(profile='default'):
964 966 """Iterate over the server info files of running notebook servers.
965 967
966 968 Given a profile name, find nbserver-* files in the security directory of
967 969 that profile, and yield dicts of their information, each one pertaining to
968 970 a currently running notebook server instance.
969 971 """
970 972 pd = ProfileDir.find_profile_dir_by_name(get_ipython_dir(), name=profile)
971 973 for file in os.listdir(pd.security_dir):
972 974 if file.startswith('nbserver-'):
973 975 with io.open(os.path.join(pd.security_dir, file), encoding='utf-8') as f:
974 976 info = json.load(f)
975 977
976 978 # Simple check whether that process is really still running
977 979 if check_pid(info['pid']):
978 980 yield info
979 981 else:
980 982 # If the process has died, try to delete its info file
981 983 try:
982 984 os.unlink(file)
983 985 except OSError:
984 986 pass # TODO: This should warn or log or something
985 987 #-----------------------------------------------------------------------------
986 988 # Main entry point
987 989 #-----------------------------------------------------------------------------
988 990
989 991 launch_new_instance = NotebookApp.launch_instance
990 992
General Comments 0
You need to be logged in to leave comments. Login now