##// END OF EJS Templates
review fixes on tests, add extra kernel api test
Zachary Sailer -
Show More
@@ -1,423 +1,419 b''
1 1 """Base Tornado handlers for the notebook.
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19
20 20 import datetime
21 21 import email.utils
22 22 import hashlib
23 23 import logging
24 24 import mimetypes
25 25 import os
26 26 import stat
27 27 import threading
28 28
29 29 from tornado import web
30 30 from tornado import websocket
31 31
32 32 try:
33 33 from tornado.log import app_log
34 34 except ImportError:
35 35 app_log = logging.getLogger()
36 36
37 37 from IPython.config import Application
38 38 from IPython.external.decorator import decorator
39 39 from IPython.utils.path import filefind
40 40
41 41 #-----------------------------------------------------------------------------
42 42 # Monkeypatch for Tornado <= 2.1.1 - Remove when no longer necessary!
43 43 #-----------------------------------------------------------------------------
44 44
45 45 # Google Chrome, as of release 16, changed its websocket protocol number. The
46 46 # parts tornado cares about haven't really changed, so it's OK to continue
47 47 # accepting Chrome connections, but as of Tornado 2.1.1 (the currently released
48 48 # version as of Oct 30/2011) the version check fails, see the issue report:
49 49
50 50 # https://github.com/facebook/tornado/issues/385
51 51
52 52 # This issue has been fixed in Tornado post 2.1.1:
53 53
54 54 # https://github.com/facebook/tornado/commit/84d7b458f956727c3b0d6710
55 55
56 56 # Here we manually apply the same patch as above so that users of IPython can
57 57 # continue to work with an officially released Tornado. We make the
58 58 # monkeypatch version check as narrow as possible to limit its effects; once
59 59 # Tornado 2.1.1 is no longer found in the wild we'll delete this code.
60 60
61 61 import tornado
62 62
63 63 if tornado.version_info <= (2,1,1):
64 64
65 65 def _execute(self, transforms, *args, **kwargs):
66 66 from tornado.websocket import WebSocketProtocol8, WebSocketProtocol76
67 67
68 68 self.open_args = args
69 69 self.open_kwargs = kwargs
70 70
71 71 # The difference between version 8 and 13 is that in 8 the
72 72 # client sends a "Sec-Websocket-Origin" header and in 13 it's
73 73 # simply "Origin".
74 74 if self.request.headers.get("Sec-WebSocket-Version") in ("7", "8", "13"):
75 75 self.ws_connection = WebSocketProtocol8(self)
76 76 self.ws_connection.accept_connection()
77 77
78 78 elif self.request.headers.get("Sec-WebSocket-Version"):
79 79 self.stream.write(tornado.escape.utf8(
80 80 "HTTP/1.1 426 Upgrade Required\r\n"
81 81 "Sec-WebSocket-Version: 8\r\n\r\n"))
82 82 self.stream.close()
83 83
84 84 else:
85 85 self.ws_connection = WebSocketProtocol76(self)
86 86 self.ws_connection.accept_connection()
87 87
88 88 websocket.WebSocketHandler._execute = _execute
89 89 del _execute
90 90
91 91
92 92 #-----------------------------------------------------------------------------
93 93 # Top-level handlers
94 94 #-----------------------------------------------------------------------------
95 95
96 96 class RequestHandler(web.RequestHandler):
97 97 """RequestHandler with default variable setting."""
98 98
99 99 def render(*args, **kwargs):
100 100 kwargs.setdefault('message', '')
101 101 return web.RequestHandler.render(*args, **kwargs)
102 102
103 103 class AuthenticatedHandler(RequestHandler):
104 104 """A RequestHandler with an authenticated user."""
105 105
106 106 def clear_login_cookie(self):
107 107 self.clear_cookie(self.cookie_name)
108 108
109 109 def get_current_user(self):
110 110 user_id = self.get_secure_cookie(self.cookie_name)
111 111 # For now the user_id should not return empty, but it could eventually
112 112 if user_id == '':
113 113 user_id = 'anonymous'
114 114 if user_id is None:
115 115 # prevent extra Invalid cookie sig warnings:
116 116 self.clear_login_cookie()
117 117 if not self.login_available:
118 118 user_id = 'anonymous'
119 119 return user_id
120 120
121 121 @property
122 122 def cookie_name(self):
123 123 default_cookie_name = 'username-{host}'.format(
124 124 host=self.request.host,
125 125 ).replace(':', '-')
126 126 return self.settings.get('cookie_name', default_cookie_name)
127 127
128 128 @property
129 129 def password(self):
130 130 """our password"""
131 131 return self.settings.get('password', '')
132 132
133 133 @property
134 134 def logged_in(self):
135 135 """Is a user currently logged in?
136 136
137 137 """
138 138 user = self.get_current_user()
139 139 return (user and not user == 'anonymous')
140 140
141 141 @property
142 142 def login_available(self):
143 143 """May a user proceed to log in?
144 144
145 145 This returns True if login capability is available, irrespective of
146 146 whether the user is already logged in or not.
147 147
148 148 """
149 149 return bool(self.settings.get('password', ''))
150 150
151 151
152 152 class IPythonHandler(AuthenticatedHandler):
153 153 """IPython-specific extensions to authenticated handling
154 154
155 155 Mostly property shortcuts to IPython-specific settings.
156 156 """
157 157
158 158 @property
159 159 def config(self):
160 160 return self.settings.get('config', None)
161 161
162 162 @property
163 163 def log(self):
164 164 """use the IPython log by default, falling back on tornado's logger"""
165 165 if Application.initialized():
166 166 return Application.instance().log
167 167 else:
168 168 return app_log
169 169
170 170 @property
171 171 def use_less(self):
172 172 """Use less instead of css in templates"""
173 173 return self.settings.get('use_less', False)
174 174
175 175 #---------------------------------------------------------------
176 176 # URLs
177 177 #---------------------------------------------------------------
178 178
179 179 @property
180 180 def ws_url(self):
181 181 """websocket url matching the current request
182 182
183 183 By default, this is just `''`, indicating that it should match
184 184 the same host, protocol, port, etc.
185 185 """
186 186 return self.settings.get('websocket_url', '')
187 187
188 188 @property
189 189 def mathjax_url(self):
190 190 return self.settings.get('mathjax_url', '')
191 191
192 192 @property
193 193 def base_project_url(self):
194 194 return self.settings.get('base_project_url', '/')
195 195
196 196 @property
197 197 def base_kernel_url(self):
198 198 return self.settings.get('base_kernel_url', '/')
199 199
200 200 #---------------------------------------------------------------
201 201 # Manager objects
202 202 #---------------------------------------------------------------
203 203
204 204 @property
205 205 def kernel_manager(self):
206 206 return self.settings['kernel_manager']
207 207
208 208 @property
209 209 def notebook_manager(self):
210 210 return self.settings['notebook_manager']
211 211
212 212 @property
213 213 def cluster_manager(self):
214 214 return self.settings['cluster_manager']
215 215
216 216 @property
217 217 def session_manager(self):
218 218 return self.settings['session_manager']
219 219
220 220 @property
221 def content_manager(self):
222 return self.settings['content_manager']
223
224 @property
225 221 def project(self):
226 222 return self.notebook_manager.notebook_dir
227 223
228 224 #---------------------------------------------------------------
229 225 # template rendering
230 226 #---------------------------------------------------------------
231 227
232 228 def get_template(self, name):
233 229 """Return the jinja template object for a given name"""
234 230 return self.settings['jinja2_env'].get_template(name)
235 231
236 232 def render_template(self, name, **ns):
237 233 ns.update(self.template_namespace)
238 234 template = self.get_template(name)
239 235 return template.render(**ns)
240 236
241 237 @property
242 238 def template_namespace(self):
243 239 return dict(
244 240 base_project_url=self.base_project_url,
245 241 base_kernel_url=self.base_kernel_url,
246 242 logged_in=self.logged_in,
247 243 login_available=self.login_available,
248 244 use_less=self.use_less,
249 245 )
250 246
251 247 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
252 248 """static files should only be accessible when logged in"""
253 249
254 250 @web.authenticated
255 251 def get(self, path):
256 252 return web.StaticFileHandler.get(self, path)
257 253
258 254
259 255 #-----------------------------------------------------------------------------
260 256 # File handler
261 257 #-----------------------------------------------------------------------------
262 258
263 259 # to minimize subclass changes:
264 260 HTTPError = web.HTTPError
265 261
266 262 class FileFindHandler(web.StaticFileHandler):
267 263 """subclass of StaticFileHandler for serving files from a search path"""
268 264
269 265 _static_paths = {}
270 266 # _lock is needed for tornado < 2.2.0 compat
271 267 _lock = threading.Lock() # protects _static_hashes
272 268
273 269 def initialize(self, path, default_filename=None):
274 270 if isinstance(path, basestring):
275 271 path = [path]
276 272 self.roots = tuple(
277 273 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in path
278 274 )
279 275 self.default_filename = default_filename
280 276
281 277 @classmethod
282 278 def locate_file(cls, path, roots):
283 279 """locate a file to serve on our static file search path"""
284 280 with cls._lock:
285 281 if path in cls._static_paths:
286 282 return cls._static_paths[path]
287 283 try:
288 284 abspath = os.path.abspath(filefind(path, roots))
289 285 except IOError:
290 286 # empty string should always give exists=False
291 287 return ''
292 288
293 289 # os.path.abspath strips a trailing /
294 290 # it needs to be temporarily added back for requests to root/
295 291 if not (abspath + os.path.sep).startswith(roots):
296 292 raise HTTPError(403, "%s is not in root static directory", path)
297 293
298 294 cls._static_paths[path] = abspath
299 295 return abspath
300 296
301 297 def get(self, path, include_body=True):
302 298 path = self.parse_url_path(path)
303 299
304 300 # begin subclass override
305 301 abspath = self.locate_file(path, self.roots)
306 302 # end subclass override
307 303
308 304 if os.path.isdir(abspath) and self.default_filename is not None:
309 305 # need to look at the request.path here for when path is empty
310 306 # but there is some prefix to the path that was already
311 307 # trimmed by the routing
312 308 if not self.request.path.endswith("/"):
313 309 self.redirect(self.request.path + "/")
314 310 return
315 311 abspath = os.path.join(abspath, self.default_filename)
316 312 if not os.path.exists(abspath):
317 313 raise HTTPError(404)
318 314 if not os.path.isfile(abspath):
319 315 raise HTTPError(403, "%s is not a file", path)
320 316
321 317 stat_result = os.stat(abspath)
322 318 modified = datetime.datetime.utcfromtimestamp(stat_result[stat.ST_MTIME])
323 319
324 320 self.set_header("Last-Modified", modified)
325 321
326 322 mime_type, encoding = mimetypes.guess_type(abspath)
327 323 if mime_type:
328 324 self.set_header("Content-Type", mime_type)
329 325
330 326 cache_time = self.get_cache_time(path, modified, mime_type)
331 327
332 328 if cache_time > 0:
333 329 self.set_header("Expires", datetime.datetime.utcnow() + \
334 330 datetime.timedelta(seconds=cache_time))
335 331 self.set_header("Cache-Control", "max-age=" + str(cache_time))
336 332 else:
337 333 self.set_header("Cache-Control", "public")
338 334
339 335 self.set_extra_headers(path)
340 336
341 337 # Check the If-Modified-Since, and don't send the result if the
342 338 # content has not been modified
343 339 ims_value = self.request.headers.get("If-Modified-Since")
344 340 if ims_value is not None:
345 341 date_tuple = email.utils.parsedate(ims_value)
346 342 if_since = datetime.datetime(*date_tuple[:6])
347 343 if if_since >= modified:
348 344 self.set_status(304)
349 345 return
350 346
351 347 with open(abspath, "rb") as file:
352 348 data = file.read()
353 349 hasher = hashlib.sha1()
354 350 hasher.update(data)
355 351 self.set_header("Etag", '"%s"' % hasher.hexdigest())
356 352 if include_body:
357 353 self.write(data)
358 354 else:
359 355 assert self.request.method == "HEAD"
360 356 self.set_header("Content-Length", len(data))
361 357
362 358 @classmethod
363 359 def get_version(cls, settings, path):
364 360 """Generate the version string to be used in static URLs.
365 361
366 362 This method may be overridden in subclasses (but note that it
367 363 is a class method rather than a static method). The default
368 364 implementation uses a hash of the file's contents.
369 365
370 366 ``settings`` is the `Application.settings` dictionary and ``path``
371 367 is the relative location of the requested asset on the filesystem.
372 368 The returned value should be a string, or ``None`` if no version
373 369 could be determined.
374 370 """
375 371 # begin subclass override:
376 372 static_paths = settings['static_path']
377 373 if isinstance(static_paths, basestring):
378 374 static_paths = [static_paths]
379 375 roots = tuple(
380 376 os.path.abspath(os.path.expanduser(p)) + os.path.sep for p in static_paths
381 377 )
382 378
383 379 try:
384 380 abs_path = filefind(path, roots)
385 381 except IOError:
386 382 app_log.error("Could not find static file %r", path)
387 383 return None
388 384
389 385 # end subclass override
390 386
391 387 with cls._lock:
392 388 hashes = cls._static_hashes
393 389 if abs_path not in hashes:
394 390 try:
395 391 f = open(abs_path, "rb")
396 392 hashes[abs_path] = hashlib.md5(f.read()).hexdigest()
397 393 f.close()
398 394 except Exception:
399 395 app_log.error("Could not open static file %r", path)
400 396 hashes[abs_path] = None
401 397 hsh = hashes.get(abs_path)
402 398 if hsh:
403 399 return hsh[:5]
404 400 return None
405 401
406 402
407 403 def parse_url_path(self, url_path):
408 404 """Converts a static URL path into a filesystem path.
409 405
410 406 ``url_path`` is the path component of the URL with
411 407 ``static_url_prefix`` removed. The return value should be
412 408 filesystem path relative to ``static_path``.
413 409 """
414 410 if os.path.sep != "/":
415 411 url_path = url_path.replace("/", os.path.sep)
416 412 return url_path
417 413
418 414 #-----------------------------------------------------------------------------
419 415 # URL to handler mappings
420 416 #-----------------------------------------------------------------------------
421 417
422 418
423 419 default_handlers = []
@@ -1,36 +1,53 b''
1 1 """Test the kernels service API."""
2 2
3 3
4 4 import os
5 5 import sys
6 6 import json
7 7
8 8 import requests
9 9
10 10 from IPython.html.tests.launchnotebook import NotebookTestBase
11 11
12 12
13 13 class KernelAPITest(NotebookTestBase):
14 14 """Test the kernels web service API"""
15 15
16 16 def base_url(self):
17 17 return super(KernelAPITest,self).base_url() + 'api/kernels'
18 18
19 def mkkernel(self):
20 r = requests.post(self.base_url())
21 return r.json()
22
19 23 def test_no_kernels(self):
20 24 """Make sure there are no kernels running at the start"""
21 25 url = self.base_url()
22 26 r = requests.get(url)
23 assert r.json() == []
27 self.assertEqual(r.json(), [])
24 28
25 29 def test_main_kernel_handler(self):
26 30 # POST request
27 31 r = requests.post(self.base_url())
28 32 data = r.json()
29 33 assert isinstance(data, dict)
30 34
31 35 # GET request
32 36 r = requests.get(self.base_url())
33 37 assert isinstance(r.json(), list)
34 38 self.assertEqual(r.json()[0], data['id'])
35 39
36 No newline at end of file
40 def test_kernel_handler(self):
41 # GET kernel with id
42 data = self.mkkernel()
43 url = self.base_url() +'/' + data['id']
44 r = requests.get(url)
45 assert isinstance(r.json(), dict)
46 self.assertIn('id', r.json())
47 self.assertEqual(r.json()['id'], data['id'])
48
49 # DELETE kernel with id
50 r = requests.delete(url)
51 self.assertEqual(r.status_code, 204)
52 r = requests.get(self.base_url())
53 self.assertEqual(r.json(), []) No newline at end of file
@@ -1,347 +1,347 b''
1 1 """A notebook manager that uses the local file system for storage.
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19 import datetime
20 20 import io
21 21 import os
22 22 import glob
23 23 import shutil
24 24
25 25 from unicodedata import normalize
26 26
27 27 from tornado import web
28 28
29 29 from .nbmanager import NotebookManager
30 30 from IPython.nbformat import current
31 31 from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
32 32 from IPython.utils import tz
33 33
34 34 #-----------------------------------------------------------------------------
35 35 # Classes
36 36 #-----------------------------------------------------------------------------
37 37
38 38 class FileNotebookManager(NotebookManager):
39 39
40 40 save_script = Bool(False, config=True,
41 41 help="""Automatically create a Python script when saving the notebook.
42 42
43 43 For easier use of import, %run and %load across notebooks, a
44 44 <notebook-name>.py script will be created next to any
45 45 <notebook-name>.ipynb on each save. This can also be set with the
46 46 short `--script` flag.
47 47 """
48 48 )
49 49
50 50 checkpoint_dir = Unicode(config=True,
51 51 help="""The location in which to keep notebook checkpoints
52 52
53 53 By default, it is notebook-dir/.ipynb_checkpoints
54 54 """
55 55 )
56 56 def _checkpoint_dir_default(self):
57 57 return os.path.join(self.notebook_dir, '.ipynb_checkpoints')
58 58
59 59 def _checkpoint_dir_changed(self, name, old, new):
60 60 """do a bit of validation of the checkpoint dir"""
61 61 if not os.path.isabs(new):
62 62 # If we receive a non-absolute path, make it absolute.
63 63 abs_new = os.path.abspath(new)
64 64 self.checkpoint_dir = abs_new
65 65 return
66 66 if os.path.exists(new) and not os.path.isdir(new):
67 67 raise TraitError("checkpoint dir %r is not a directory" % new)
68 68 if not os.path.exists(new):
69 69 self.log.info("Creating checkpoint dir %s", new)
70 70 try:
71 71 os.mkdir(new)
72 72 except:
73 73 raise TraitError("Couldn't create checkpoint dir %r" % new)
74 74
75 75 filename_ext = Unicode(u'.ipynb')
76 76
77 77
78 78 def get_notebook_names(self, path):
79 79 """List all notebook names in the notebook dir."""
80 80 names = glob.glob(self.get_os_path('*'+self.filename_ext, path))
81 81 names = [os.path.basename(name)
82 82 for name in names]
83 83 return names
84 84
85 85 def list_notebooks(self, path):
86 86 """List all notebooks in the notebook dir."""
87 87 notebook_names = self.get_notebook_names(path)
88 88 notebooks = []
89 89 for name in notebook_names:
90 90 model = self.notebook_model(name, path, content=False)
91 91 notebooks.append(model)
92 92 return notebooks
93 93
94 def change_notebook(self, data, notebook_name, notebook_path='/'):
94 def update_notebook(self, data, notebook_name, notebook_path='/'):
95 95 """Changes notebook"""
96 96 changes = data.keys()
97 97 for change in changes:
98 98 full_path = self.get_os_path(notebook_name, notebook_path)
99 99 if change == "name":
100 100 new_path = self.get_os_path(data['name'], notebook_path)
101 101 if not os.path.isfile(new_path):
102 102 os.rename(full_path,
103 103 self.get_os_path(data['name'], notebook_path))
104 104 notebook_name = data['name']
105 105 else:
106 106 raise web.HTTPError(409, u'Notebook name already exists.')
107 107 if change == "path":
108 108 new_path = self.get_os_path(data['name'], data['path'])
109 109 stutil.move(full_path, new_path)
110 110 notebook_path = data['path']
111 111 if change == "content":
112 112 self.save_notebook(data, notebook_name, notebook_path)
113 113 model = self.notebook_model(notebook_name, notebook_path)
114 114 return model
115 115
116 116 def notebook_exists(self, name, path):
117 117 """Returns a True if the notebook exists. Else, returns False.
118 118
119 119 Parameters
120 120 ----------
121 121 name : string
122 122 The name of the notebook you are checking.
123 123 path : string
124 124 The relative path to the notebook (with '/' as separator)
125 125
126 126 Returns
127 127 -------
128 128 bool
129 129 """
130 130 path = self.get_os_path(name, path)
131 131 return os.path.isfile(path)
132 132
133 133 def read_notebook_object_from_path(self, path):
134 134 """read a notebook object from a path"""
135 135 info = os.stat(path)
136 136 last_modified = tz.utcfromtimestamp(info.st_mtime)
137 137 with open(path,'r') as f:
138 138 s = f.read()
139 139 try:
140 140 # v1 and v2 and json in the .ipynb files.
141 141 nb = current.reads(s, u'json')
142 142 except ValueError as e:
143 143 msg = u"Unreadable Notebook: %s" % e
144 144 raise web.HTTPError(400, msg, reason=msg)
145 145 return last_modified, nb
146 146
147 147 def read_notebook_object(self, notebook_name, notebook_path='/'):
148 148 """Get the Notebook representation of a notebook by notebook_name."""
149 149 path = self.get_os_path(notebook_name, notebook_path)
150 150 if not os.path.isfile(path):
151 151 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_name)
152 152 last_modified, nb = self.read_notebook_object_from_path(path)
153 153 # Always use the filename as the notebook name.
154 154 # Eventually we will get rid of the notebook name in the metadata
155 155 # but for now, that name is just an empty string. Until the notebooks
156 156 # web service knows about names in URLs we still pass the name
157 157 # back to the web app using the metadata though.
158 158 nb.metadata.name = os.path.splitext(os.path.basename(path))[0]
159 159 return last_modified, nb
160 160
161 161 def write_notebook_object(self, nb, notebook_name=None, notebook_path='/', new_name= None):
162 162 """Save an existing notebook object by notebook_name."""
163 163 if new_name == None:
164 164 try:
165 165 new_name = normalize('NFC', nb.metadata.name)
166 166 except AttributeError:
167 167 raise web.HTTPError(400, u'Missing notebook name')
168 168
169 169 new_path = notebook_path
170 170 old_name = notebook_name
171 171 old_checkpoints = self.list_checkpoints(old_name)
172 172
173 173 path = self.get_os_path(new_name, new_path)
174 174
175 175 # Right before we save the notebook, we write an empty string as the
176 176 # notebook name in the metadata. This is to prepare for removing
177 177 # this attribute entirely post 1.0. The web app still uses the metadata
178 178 # name for now.
179 179 nb.metadata.name = u''
180 180
181 181 try:
182 182 self.log.debug("Autosaving notebook %s", path)
183 183 with open(path,'w') as f:
184 184 current.write(nb, f, u'json')
185 185 except Exception as e:
186 186 raise web.HTTPError(400, u'Unexpected error while autosaving notebook: %s' % e)
187 187
188 188 # save .py script as well
189 189 if self.save_script:
190 190 pypath = os.path.splitext(path)[0] + '.py'
191 191 self.log.debug("Writing script %s", pypath)
192 192 try:
193 193 with io.open(pypath,'w', encoding='utf-8') as f:
194 194 current.write(nb, f, u'py')
195 195 except Exception as e:
196 196 raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s' % e)
197 197
198 198 if old_name != None:
199 199 # remove old files if the name changed
200 200 if old_name != new_name:
201 201 # remove renamed original, if it exists
202 202 old_path = self.get_os_path(old_name, notebook_path)
203 203 if os.path.isfile(old_path):
204 204 self.log.debug("unlinking notebook %s", old_path)
205 205 os.unlink(old_path)
206 206
207 207 # cleanup old script, if it exists
208 208 if self.save_script:
209 209 old_pypath = os.path.splitext(old_path)[0] + '.py'
210 210 if os.path.isfile(old_pypath):
211 211 self.log.debug("unlinking script %s", old_pypath)
212 212 os.unlink(old_pypath)
213 213
214 214 # rename checkpoints to follow file
215 215 for cp in old_checkpoints:
216 216 checkpoint_id = cp['checkpoint_id']
217 217 old_cp_path = self.get_checkpoint_path_by_name(old_name, checkpoint_id)
218 218 new_cp_path = self.get_checkpoint_path_by_name(new_name, checkpoint_id)
219 219 if os.path.isfile(old_cp_path):
220 220 self.log.debug("renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
221 221 os.rename(old_cp_path, new_cp_path)
222 222
223 223 return new_name
224 224
225 225 def delete_notebook(self, notebook_name, notebook_path):
226 226 """Delete notebook by notebook_name."""
227 227 nb_path = self.get_os_path(notebook_name, notebook_path)
228 228 if not os.path.isfile(nb_path):
229 229 raise web.HTTPError(404, u'Notebook does not exist: %s' % notebook_name)
230 230
231 231 # clear checkpoints
232 232 for checkpoint in self.list_checkpoints(notebook_name):
233 233 checkpoint_id = checkpoint['checkpoint_id']
234 234 path = self.get_checkpoint_path(notebook_name, checkpoint_id)
235 235 self.log.debug(path)
236 236 if os.path.isfile(path):
237 237 self.log.debug("unlinking checkpoint %s", path)
238 238 os.unlink(path)
239 239
240 240 self.log.debug("unlinking notebook %s", nb_path)
241 241 os.unlink(nb_path)
242 242
243 243 def increment_filename(self, basename, notebook_path='/'):
244 244 """Return a non-used filename of the form basename<int>.
245 245
246 246 This searches through the filenames (basename0, basename1, ...)
247 247 until is find one that is not already being used. It is used to
248 248 create Untitled and Copy names that are unique.
249 249 """
250 250 i = 0
251 251 while True:
252 252 name = u'%s%i.ipynb' % (basename,i)
253 253 path = self.get_os_path(name, notebook_path)
254 254 if not os.path.isfile(path):
255 255 break
256 256 else:
257 257 i = i+1
258 258 return name
259 259
260 260 # Checkpoint-related utilities
261 261
262 262 def get_checkpoint_path_by_name(self, name, checkpoint_id, notebook_path='/'):
263 263 """Return a full path to a notebook checkpoint, given its name and checkpoint id."""
264 264 filename = u"{name}-{checkpoint_id}{ext}".format(
265 265 name=name,
266 266 checkpoint_id=checkpoint_id,
267 267 ext=self.filename_ext,
268 268 )
269 269 if notebook_path ==None:
270 270 path = os.path.join(self.checkpoint_dir, filename)
271 271 else:
272 272 path = os.path.join(notebook_path, self.checkpoint_dir, filename)
273 273 return path
274 274
275 275 def get_checkpoint_path(self, notebook_name, checkpoint_id, notebook_path='/'):
276 276 """find the path to a checkpoint"""
277 277 name = notebook_name
278 278 return self.get_checkpoint_path_by_name(name, checkpoint_id, notebook_path)
279 279
280 280 def get_checkpoint_info(self, notebook_name, checkpoint_id, notebook_path='/'):
281 281 """construct the info dict for a given checkpoint"""
282 282 path = self.get_checkpoint_path(notebook_name, checkpoint_id, notebook_path)
283 283 stats = os.stat(path)
284 284 last_modified = tz.utcfromtimestamp(stats.st_mtime)
285 285 info = dict(
286 286 checkpoint_id = checkpoint_id,
287 287 last_modified = last_modified,
288 288 )
289 289
290 290 return info
291 291
292 292 # public checkpoint API
293 293
294 294 def create_checkpoint(self, notebook_name, notebook_path='/'):
295 295 """Create a checkpoint from the current state of a notebook"""
296 296 nb_path = self.get_os_path(notebook_name, notebook_path)
297 297 # only the one checkpoint ID:
298 298 checkpoint_id = u"checkpoint"
299 299 cp_path = self.get_checkpoint_path(notebook_name, checkpoint_id, notebook_path)
300 300 self.log.debug("creating checkpoint for notebook %s", notebook_name)
301 301 if not os.path.exists(self.checkpoint_dir):
302 302 os.mkdir(self.checkpoint_dir)
303 303 shutil.copy2(nb_path, cp_path)
304 304
305 305 # return the checkpoint info
306 306 return self.get_checkpoint_info(notebook_name, checkpoint_id, notebook_path)
307 307
308 308 def list_checkpoints(self, notebook_name, notebook_path='/'):
309 309 """list the checkpoints for a given notebook
310 310
311 311 This notebook manager currently only supports one checkpoint per notebook.
312 312 """
313 313 checkpoint_id = "checkpoint"
314 314 path = self.get_checkpoint_path(notebook_name, checkpoint_id, notebook_path)
315 315 if not os.path.exists(path):
316 316 return []
317 317 else:
318 318 return [self.get_checkpoint_info(notebook_name, checkpoint_id, notebook_path)]
319 319
320 320
321 321 def restore_checkpoint(self, notebook_name, checkpoint_id, notebook_path='/'):
322 322 """restore a notebook to a checkpointed state"""
323 323 self.log.info("restoring Notebook %s from checkpoint %s", notebook_name, checkpoint_id)
324 324 nb_path = self.get_os_path(notebook_name, notebook_path)
325 325 cp_path = self.get_checkpoint_path(notebook_name, checkpoint_id, notebook_path)
326 326 if not os.path.isfile(cp_path):
327 327 self.log.debug("checkpoint file does not exist: %s", cp_path)
328 328 raise web.HTTPError(404,
329 329 u'Notebook checkpoint does not exist: %s-%s' % (notebook_name, checkpoint_id)
330 330 )
331 331 # ensure notebook is readable (never restore from an unreadable notebook)
332 332 last_modified, nb = self.read_notebook_object_from_path(cp_path)
333 333 shutil.copy2(cp_path, nb_path)
334 334 self.log.debug("copying %s -> %s", cp_path, nb_path)
335 335
336 336 def delete_checkpoint(self, notebook_name, checkpoint_id, notebook_path='/'):
337 337 """delete a notebook's checkpoint"""
338 338 path = self.get_checkpoint_path(notebook_name, checkpoint_id, notebook_path)
339 339 if not os.path.isfile(path):
340 340 raise web.HTTPError(404,
341 341 u'Notebook checkpoint does not exist: %s-%s' % (notebook_name, checkpoint_id)
342 342 )
343 343 self.log.debug("unlinking %s", path)
344 344 os.unlink(path)
345 345
346 346 def info_string(self):
347 347 return "Serving notebooks from local directory: %s" % self.notebook_dir
@@ -1,217 +1,206 b''
1 1 """Tornado handlers for the notebooks web service.
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2008-2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 from tornado import web
19 import json
20 20
21 from zmq.utils import jsonapi
21 from tornado import web
22 22
23 from ...utils import url_path_join
23 24 from IPython.utils.jsonutil import date_default
24 25
25 from ...base.handlers import IPythonHandler
26 from ...base.handlers import IPythonHandler, json_errors
26 27
27 28 #-----------------------------------------------------------------------------
28 29 # Notebook web service handlers
29 30 #-----------------------------------------------------------------------------
30 31
31 32
32 class NotebookRootHandler(IPythonHandler):
33
34 @web.authenticated
35 def get(self):
36 """get returns a list of notebooks from the location
37 where the server was started."""
38 nbm = self.notebook_manager
39 notebooks = nbm.list_notebooks("/")
40 self.finish(jsonapi.dumps(notebooks))
41
42 @web.authenticated
43 def post(self):
44 """post creates a notebooks in the directory where the
45 server was started"""
46 nbm = self.notebook_manager
47 self.log.info(nbm.notebook_dir)
48 body = self.request.body.strip()
49 format = self.get_argument('format', default='json')
50 name = self.get_argument('name', default=None)
51 if body:
52 fname = nbm.save_new_notebook(body, notebook_path='/', name=name, format=format)
53 else:
54 fname = nbm.new_notebook(notebook_path='/')
55 self.set_header('Location', nbm.notebook_dir + fname)
56 model = nbm.notebook_model(fname)
57 self.set_header('Location', '{0}api/notebooks/{1}'.format(self.base_project_url, fname))
58 self.finish(jsonapi.dumps(model))
59
60 33 class NotebookHandler(IPythonHandler):
61 34
62 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST','DELETE')
35 SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE')
36
37 def notebook_location(self, name, path):
38 """Return the full URL location of a notebook based.
39
40 Parameters
41 ----------
42 name : unicode
43 The name of the notebook like "foo.ipynb".
44 path : unicode
45 The URL path of the notebook.
46 """
47 return url_path_join(self.base_project_url, u'/api/notebooks', path, name)
63 48
64 49 @web.authenticated
50 @json_errors
65 51 def get(self, notebook_path):
66 52 """get checks if a notebook is not named, an returns a list of notebooks
67 53 in the notebook path given. If a name is given, return
68 54 the notebook representation"""
69 55 nbm = self.notebook_manager
56 # path will have leading and trailing slashes, such as '/foo/bar/'
70 57 name, path = nbm.named_notebook_path(notebook_path)
71 58
72 59 # Check to see if a notebook name was given
73 60 if name is None:
74 61 # List notebooks in 'notebook_path'
75 62 notebooks = nbm.list_notebooks(path)
76 self.finish(jsonapi.dumps(notebooks))
63 self.finish(json.dumps(notebooks, default=date_default))
77 64 else:
78 65 # get and return notebook representation
79 format = self.get_argument('format', default='json')
80 download = self.get_argument('download', default='False')
81 model = nbm.notebook_model(name, path)
82 last_mod, representation, name = nbm.get_notebook(name, path, format)
83 self.set_header('Last-Modified', last_mod)
84
85 if download == 'True':
86 if format == u'json':
87 self.set_header('Content-Type', 'application/json')
88 self.set_header('Content-Disposition','attachment; filename="%s.ipynb"' % name)
89 self.finish(representation)
90 elif format == u'py':
91 self.set_header('Content-Type', 'application/x-python')
92 self.set_header('Content-Disposition','attachment; filename="%s.py"' % name)
93 self.finish(representation)
94 else:
95 self.finish(jsonapi.dumps(model))
66 model = nbm.get_notebook_model(name, path)
67 self.set_header(u'Last-Modified', model[u'last_modified'])
68 self.finish(json.dumps(model, default=date_default))
96 69
97 70 @web.authenticated
71 # @json_errors
98 72 def patch(self, notebook_path):
99 73 """patch is currently used strictly for notebook renaming.
100 74 Changes the notebook name to the name given in data."""
101 75 nbm = self.notebook_manager
76 # path will have leading and trailing slashes, such as '/foo/bar/'
102 77 name, path = nbm.named_notebook_path(notebook_path)
103 data = jsonapi.loads(self.request.body)
104 model = nbm.change_notebook(data, name, path)
105 self.finish(jsonapi.dumps(model))
78 if name is None:
79 raise web.HTTPError(400, u'Notebook name missing')
80 model = self.get_json_body()
81 if model is None:
82 raise web.HTTPError(400, u'JSON body missing')
83 model = nbm.update_notebook_model(model, name, path)
84 if model[u'name'] != name or model[u'path'] != path:
85 self.set_status(301)
86 location = self.notebook_location(model[u'name'], model[u'path'])
87 self.set_header(u'Location', location)
88 self.set_header(u'Last-Modified', model[u'last_modified'])
89 self.finish(json.dumps(model, default=date_default))
106 90
107 91 @web.authenticated
108 def post(self,notebook_path):
92 @json_errors
93 def post(self, notebook_path):
109 94 """Create a new notebook in the location given by 'notebook_path'."""
110 95 nbm = self.notebook_manager
111 fname, path = nbm.named_notebook_path(notebook_path)
112 body = self.request.body.strip()
113 format = self.get_argument('format', default='json')
114 name = self.get_argument('name', default=None)
115 if body:
116 fname = nbm.save_new_notebook(body, notebook_path=path, name=name, format=format)
117 else:
118 fname = nbm.new_notebook(notebook_path=path)
119 self.set_header('Location', nbm.notebook_dir + path + fname)
120 model = nbm.notebook_model(fname, path)
121 self.finish(jsonapi.dumps(model))
96 # path will have leading and trailing slashes, such as '/foo/bar/'
97 name, path = nbm.named_notebook_path(notebook_path)
98 model = self.get_json_body()
99 if name is not None:
100 raise web.HTTPError(400, 'No name can be provided when POSTing a new notebook.')
101 model = nbm.create_notebook_model(model, path)
102 location = nbm.notebook_dir + model[u'path'] + model[u'name']
103 location = self.notebook_location(model[u'name'], model[u'path'])
104 self.set_header(u'Location', location)
105 self.set_header(u'Last-Modified', model[u'last_modified'])
106 self.set_status(201)
107 self.finish(json.dumps(model, default=date_default))
122 108
123 109 @web.authenticated
110 @json_errors
124 111 def put(self, notebook_path):
125 112 """saves the notebook in the location given by 'notebook_path'."""
126 113 nbm = self.notebook_manager
114 # path will have leading and trailing slashes, such as '/foo/bar/'
127 115 name, path = nbm.named_notebook_path(notebook_path)
128 format = self.get_argument('format', default='json')
129 nbm.save_notebook(self.request.body, notebook_path=path, name=name, format=format)
130 model = nbm.notebook_model(name, path)
131 self.set_status(204)
132 self.finish(jsonapi.dumps(model))
116 model = self.get_json_body()
117 if model is None:
118 raise web.HTTPError(400, u'JSON body missing')
119 nbm.save_notebook_model(model, name, path)
120 self.finish(json.dumps(model, default=date_default))
133 121
134 122 @web.authenticated
123 @json_errors
135 124 def delete(self, notebook_path):
136 """delete rmoves the notebook in the given notebook path"""
125 """delete the notebook in the given notebook path"""
137 126 nbm = self.notebook_manager
127 # path will have leading and trailing slashes, such as '/foo/bar/'
138 128 name, path = nbm.named_notebook_path(notebook_path)
139 nbm.delete_notebook(name, path)
129 nbm.delete_notebook_model(name, path)
140 130 self.set_status(204)
141 131 self.finish()
142 132
143 133
144 134 class NotebookCheckpointsHandler(IPythonHandler):
145 135
146 136 SUPPORTED_METHODS = ('GET', 'POST')
147 137
148 138 @web.authenticated
139 @json_errors
149 140 def get(self, notebook_path):
150 141 """get lists checkpoints for a notebook"""
151 142 nbm = self.notebook_manager
143 # path will have leading and trailing slashes, such as '/foo/bar/'
152 144 name, path = nbm.named_notebook_path(notebook_path)
153 145 checkpoints = nbm.list_checkpoints(name, path)
154 data = jsonapi.dumps(checkpoints, default=date_default)
146 data = json.dumps(checkpoints, default=date_default)
155 147 self.finish(data)
156 148
157 149 @web.authenticated
150 @json_errors
158 151 def post(self, notebook_path):
159 152 """post creates a new checkpoint"""
160 153 nbm = self.notebook_manager
161 154 name, path = nbm.named_notebook_path(notebook_path)
155 # path will have leading and trailing slashes, such as '/foo/bar/'
162 156 checkpoint = nbm.create_checkpoint(name, path)
163 data = jsonapi.dumps(checkpoint, default=date_default)
164 if path == None:
165 self.set_header('Location', '{0}notebooks/{1}/checkpoints/{2}'.format(
166 self.base_project_url, name, checkpoint['checkpoint_id']
167 ))
168 else:
169 self.set_header('Location', '{0}notebooks/{1}/{2}/checkpoints/{3}'.format(
170 self.base_project_url, path, name, checkpoint['checkpoint_id']
171 ))
157 data = json.dumps(checkpoint, default=date_default)
158 location = url_path_join(self.base_project_url, u'/api/notebooks',
159 path, name, '/checkpoints', checkpoint[u'checkpoint_id'])
160 self.set_header(u'Location', location)
172 161 self.finish(data)
173 162
174 163
175 164 class ModifyNotebookCheckpointsHandler(IPythonHandler):
176 165
177 166 SUPPORTED_METHODS = ('POST', 'DELETE')
178 167
179 168 @web.authenticated
169 @json_errors
180 170 def post(self, notebook_path, checkpoint_id):
181 171 """post restores a notebook from a checkpoint"""
182 172 nbm = self.notebook_manager
173 # path will have leading and trailing slashes, such as '/foo/bar/'
183 174 name, path = nbm.named_notebook_path(notebook_path)
184 nbm.restore_checkpoint(name, checkpoint_id, path)
175 nbm.restore_checkpoint(checkpoint_id, name, path)
185 176 self.set_status(204)
186 177 self.finish()
187 178
188 179 @web.authenticated
180 @json_errors
189 181 def delete(self, notebook_path, checkpoint_id):
190 182 """delete clears a checkpoint for a given notebook"""
191 183 nbm = self.notebook_manager
184 # path will have leading and trailing slashes, such as '/foo/bar/'
192 185 name, path = nbm.named_notebook_path(notebook_path)
193 nbm.delete_checkpoint(name, checkpoint_id, path)
186 nbm.delete_checkpoint(checkpoint_id, name, path)
194 187 self.set_status(204)
195 188 self.finish()
196 189
197 190 #-----------------------------------------------------------------------------
198 191 # URL to handler mappings
199 192 #-----------------------------------------------------------------------------
200 193
201 194
202 _notebook_path_regex = r"(?P<notebook_path>.+)"
195 _notebook_path_regex = r"(?P<notebook_path>.*)"
203 196 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
204 197
205 198 default_handlers = [
206 (r"api/notebooks/%s/checkpoints" % _notebook_path_regex, NotebookCheckpointsHandler),
207 (r"api/notebooks/%s/checkpoints/%s" % (_notebook_path_regex, _checkpoint_id_regex),
199 (r"/api/notebooks/%s/checkpoints" % _notebook_path_regex, NotebookCheckpointsHandler),
200 (r"/api/notebooks/%s/checkpoints/%s" % (_notebook_path_regex, _checkpoint_id_regex),
208 201 ModifyNotebookCheckpointsHandler),
209 (r"api/notebooks/%s/" % _notebook_path_regex, NotebookHandler),
210 (r"api/notebooks/%s" % _notebook_path_regex, NotebookHandler),
211 (r"api/notebooks/", NotebookRootHandler),
212 (r"api/notebooks", NotebookRootHandler),
202 (r"/api/notebooks%s" % _notebook_path_regex, NotebookHandler),
213 203 ]
214 204
215 205
216 206
217
@@ -1,289 +1,301 b''
1 1 """A base class notebook manager.
2 2
3 3 Authors:
4 4
5 5 * Brian Granger
6 6 """
7 7
8 8 #-----------------------------------------------------------------------------
9 9 # Copyright (C) 2011 The IPython Development Team
10 10 #
11 11 # Distributed under the terms of the BSD License. The full license is in
12 12 # the file COPYING, distributed as part of this software.
13 13 #-----------------------------------------------------------------------------
14 14
15 15 #-----------------------------------------------------------------------------
16 16 # Imports
17 17 #-----------------------------------------------------------------------------
18 18
19 19 import os
20 20 import uuid
21 21
22 22 from tornado import web
23 23 from urllib import quote, unquote
24 24
25 25 from IPython.config.configurable import LoggingConfigurable
26 26 from IPython.nbformat import current
27 27 from IPython.utils.traitlets import List, Dict, Unicode, TraitError
28 28
29 29 #-----------------------------------------------------------------------------
30 30 # Classes
31 31 #-----------------------------------------------------------------------------
32 32
33 33 class NotebookManager(LoggingConfigurable):
34 34
35 35 # Todo:
36 36 # The notebook_dir attribute is used to mean a couple of different things:
37 37 # 1. Where the notebooks are stored if FileNotebookManager is used.
38 38 # 2. The cwd of the kernel for a project.
39 39 # Right now we use this attribute in a number of different places and
40 40 # we are going to have to disentangle all of this.
41 41 notebook_dir = Unicode(os.getcwdu(), config=True, help="""
42 42 The directory to use for notebooks.
43 43 """)
44 44
45 45 def named_notebook_path(self, notebook_path):
46 46 """Given a notebook_path name, returns a (name, path) tuple, where
47 47 name is a .ipynb file, and path is the directory for the file, which
48 48 *always* starts *and* ends with a '/' character.
49 49
50 50 Parameters
51 51 ----------
52 52 notebook_path : string
53 53 A path that may be a .ipynb name or a directory
54 54
55 55 Returns
56 56 -------
57 57 name : string or None
58 58 the filename of the notebook, or None if not a .ipynb extension
59 59 path : string
60 60 the path to the directory which contains the notebook
61 61 """
62 62 names = notebook_path.split('/')
63 63 names = [n for n in names if n != ''] # remove duplicate splits
64 64
65 65 names = [''] + names
66 66
67 67 if names and names[-1].endswith(".ipynb"):
68 68 name = names[-1]
69 69 path = "/".join(names[:-1]) + '/'
70 70 else:
71 71 name = None
72 72 path = "/".join(names) + '/'
73 73 return name, path
74 74
75 75 def get_os_path(self, fname=None, path='/'):
76 76 """Given a notebook name and a server URL path, return its file system
77 77 path.
78 78
79 79 Parameters
80 80 ----------
81 81 fname : string
82 82 The name of a notebook file with the .ipynb extension
83 83 path : string
84 84 The relative URL path (with '/' as separator) to the named
85 85 notebook.
86 86
87 87 Returns
88 88 -------
89 89 path : string
90 90 A file system path that combines notebook_dir (location where
91 91 server started), the relative path, and the filename with the
92 92 current operating system's url.
93 93 """
94 94 parts = path.split('/')
95 95 parts = [p for p in parts if p != ''] # remove duplicate splits
96 96 if fname is not None:
97 97 parts += [fname]
98 98 path = os.path.join(self.notebook_dir, *parts)
99 99 return path
100 100
101 101 def url_encode(self, path):
102 102 """Returns the path with all special characters URL encoded"""
103 103 parts = os.path.split(path)
104 104 return os.path.join(*[quote(p) for p in parts])
105 105
106 106 def url_decode(self, path):
107 107 """Returns the URL with special characters decoded"""
108 108 parts = os.path.split(path)
109 109 return os.path.join(*[unquote(p) for p in parts])
110 110
111 111 def _notebook_dir_changed(self, new):
112 112 """do a bit of validation of the notebook dir"""
113 113 if not os.path.isabs(new):
114 114 # If we receive a non-absolute path, make it absolute.
115 115 abs_new = os.path.abspath(new)
116 116 #self.notebook_dir = os.path.dirname(abs_new)
117 117 return
118 118 if os.path.exists(new) and not os.path.isdir(new):
119 119 raise TraitError("notebook dir %r is not a directory" % new)
120 120 if not os.path.exists(new):
121 121 self.log.info("Creating notebook dir %s", new)
122 122 try:
123 123 os.mkdir(new)
124 124 except:
125 125 raise TraitError("Couldn't create notebook dir %r" % new)
126 126
127 127 allowed_formats = List([u'json',u'py'])
128 128
129 129 def add_new_folder(self, path=None):
130 130 new_path = os.path.join(self.notebook_dir, path)
131 131 if not os.path.exists(new_path):
132 132 os.makedirs(new_path)
133 133 else:
134 134 raise web.HTTPError(409, u'Directory already exists or creation permission not allowed.')
135 135
136 136 def load_notebook_names(self, path):
137 137 """Load the notebook names into memory.
138 138
139 139 This should be called once immediately after the notebook manager
140 140 is created to load the existing notebooks into the mapping in
141 141 memory.
142 142 """
143 143 self.list_notebooks(path)
144 144
145 145 def list_notebooks(self):
146 146 """List all notebooks.
147 147
148 148 This returns a list of dicts, each of the form::
149 149
150 150 dict(notebook_id=notebook,name=name)
151 151
152 152 This list of dicts should be sorted by name::
153 153
154 154 data = sorted(data, key=lambda item: item['name'])
155 155 """
156 156 raise NotImplementedError('must be implemented in a subclass')
157 157
158 def notebook_model(self, notebook_name, notebook_path='/', content=True):
158 def notebook_model(self, name, path='/', content=True):
159 159 """ Creates the standard notebook model """
160 last_modified, contents = self.read_notebook_object(notebook_name, notebook_path)
161 model = {"name": notebook_name,
162 "path": notebook_path,
163 "last_modified (UTC)": last_modified.ctime()}
160 last_modified, contents = self.read_notebook_model(name, path)
161 model = {"name": name,
162 "path": path,
163 "last_modified": last_modified.ctime()}
164 164 if content is True:
165 165 model['content'] = contents
166 166 return model
167 167
168 168 def get_notebook(self, notebook_name, notebook_path='/', format=u'json'):
169 169 """Get the representation of a notebook in format by notebook_name."""
170 170 format = unicode(format)
171 171 if format not in self.allowed_formats:
172 172 raise web.HTTPError(415, u'Invalid notebook format: %s' % format)
173 173 kwargs = {}
174 174 last_mod, nb = self.read_notebook_object(notebook_name, notebook_path)
175 175 if format == 'json':
176 176 # don't split lines for sending over the wire, because it
177 177 # should match the Python in-memory format.
178 178 kwargs['split_lines'] = False
179 179 representation = current.writes(nb, format, **kwargs)
180 180 name = nb.metadata.get('name', 'notebook')
181 181 return last_mod, representation, name
182 182
183 def read_notebook_object(self, notebook_name, notebook_path='/'):
183 def read_notebook_model(self, notebook_name, notebook_path='/'):
184 184 """Get the object representation of a notebook by notebook_id."""
185 185 raise NotImplementedError('must be implemented in a subclass')
186 186
187 def save_notebook(self, model, name=None, path='/'):
188 """Save the Notebook"""
189 if name is None:
190 name = self.increment_filename('Untitled', path)
191 if 'content' not in model:
192 metadata = current.new_metadata(name=name)
193 nb = current.new_notebook(metadata=metadata)
194 else:
195 nb = model['content']
196 self.write_notebook_object()
197
198
187 199 def save_new_notebook(self, data, notebook_path='/', name=None, format=u'json'):
188 200 """Save a new notebook and return its name.
189 201
190 202 If a name is passed in, it overrides any values in the notebook data
191 203 and the value in the data is updated to use that value.
192 204 """
193 205 if format not in self.allowed_formats:
194 206 raise web.HTTPError(415, u'Invalid notebook format: %s' % format)
195 207
196 208 try:
197 209 nb = current.reads(data.decode('utf-8'), format)
198 210 except:
199 211 raise web.HTTPError(400, u'Invalid JSON data')
200 212
201 213 if name is None:
202 214 try:
203 215 name = nb.metadata.name
204 216 except AttributeError:
205 217 raise web.HTTPError(400, u'Missing notebook name')
206 218 nb.metadata.name = name
207 219
208 220 notebook_name = self.write_notebook_object(nb, notebook_path=notebook_path)
209 221 return notebook_name
210 222
211 def save_notebook(self, data, notebook_path='/', name=None, new_name=None, format=u'json'):
223 def save_notebook(self, data, notebook_path='/', name=None, format=u'json'):
212 224 """Save an existing notebook by notebook_name."""
213 225 if format not in self.allowed_formats:
214 226 raise web.HTTPError(415, u'Invalid notebook format: %s' % format)
215 227
216 228 try:
217 229 nb = current.reads(data.decode('utf-8'), format)
218 230 except:
219 231 raise web.HTTPError(400, u'Invalid JSON data')
220 232
221 233 if name is not None:
222 234 nb.metadata.name = name
223 235 self.write_notebook_object(nb, name, notebook_path, new_name)
224 236
225 def write_notebook_object(self, nb, notebook_name='/', notebook_path='/', new_name=None):
237 def write_notebook_model(self, model):
226 238 """Write a notebook object and return its notebook_name.
227 239
228 240 If notebook_name is None, this method should create a new notebook_name.
229 241 If notebook_name is not None, this method should check to make sure it
230 242 exists and is valid.
231 243 """
232 244 raise NotImplementedError('must be implemented in a subclass')
233 245
234 246 def delete_notebook(self, notebook_name, notebook_path):
235 247 """Delete notebook by notebook_id."""
236 248 raise NotImplementedError('must be implemented in a subclass')
237 249
238 250 def increment_filename(self, name):
239 251 """Increment a filename to make it unique.
240 252
241 253 This exists for notebook stores that must have unique names. When a notebook
242 254 is created or copied this method constructs a unique filename, typically
243 255 by appending an integer to the name.
244 256 """
245 257 return name
246 258
247 259 def new_notebook(self, notebook_path='/'):
248 260 """Create a new notebook and return its notebook_name."""
249 261 name = self.increment_filename('Untitled', notebook_path)
250 262 metadata = current.new_metadata(name=name)
251 263 nb = current.new_notebook(metadata=metadata)
252 264 notebook_name = self.write_notebook_object(nb, notebook_path=notebook_path)
253 265 return notebook_name
254 266
255 267 def copy_notebook(self, name, path='/'):
256 268 """Copy an existing notebook and return its new notebook_name."""
257 269 last_mod, nb = self.read_notebook_object(name, path)
258 270 name = nb.metadata.name + '-Copy'
259 271 name = self.increment_filename(name, path)
260 272 nb.metadata.name = name
261 273 notebook_name = self.write_notebook_object(nb, notebook_path = path)
262 274 return notebook_name
263 275
264 276 # Checkpoint-related
265 277
266 278 def create_checkpoint(self, notebook_name, notebook_path='/'):
267 279 """Create a checkpoint of the current state of a notebook
268 280
269 281 Returns a checkpoint_id for the new checkpoint.
270 282 """
271 283 raise NotImplementedError("must be implemented in a subclass")
272 284
273 285 def list_checkpoints(self, notebook_name, notebook_path='/'):
274 286 """Return a list of checkpoints for a given notebook"""
275 287 return []
276 288
277 289 def restore_checkpoint(self, notebook_name, checkpoint_id, notebook_path='/'):
278 290 """Restore a notebook from one of its checkpoints"""
279 291 raise NotImplementedError("must be implemented in a subclass")
280 292
281 293 def delete_checkpoint(self, notebook_name, checkpoint_id, notebook_path='/'):
282 294 """delete a checkpoint for a notebook"""
283 295 raise NotImplementedError("must be implemented in a subclass")
284 296
285 297 def log_info(self):
286 298 self.log.info(self.info_string())
287 299
288 300 def info_string(self):
289 301 return "Serving notebooks"
@@ -1,116 +1,115 b''
1 1 """Test the notebooks webservice API."""
2 2
3 3
4 4 import os
5 5 import sys
6 6 import json
7 7 from zmq.utils import jsonapi
8 8
9 9 import requests
10 10
11 11 from IPython.html.tests.launchnotebook import NotebookTestBase
12 12
13 13 class APITest(NotebookTestBase):
14 14 """Test the kernels web service API"""
15 15
16 16 def notebook_url(self):
17 17 return super(APITest,self).base_url() + 'api/notebooks'
18 18
19 19 def mknb(self, name='', path='/'):
20 20 url = self.notebook_url() + path
21 21 return url, requests.post(url)
22 22
23 23 def delnb(self, name, path='/'):
24 24 url = self.notebook_url() + path + name
25 25 r = requests.delete(url)
26 26 return r.status_code
27 27
28 def test_notebook_root_handler(self):
28 def test_notebook_handler(self):
29 29 # POST a notebook and test the dict thats returned.
30 30 #url, nb = self.mknb()
31 31 url = self.notebook_url()
32 32 nb = requests.post(url)
33 33 data = nb.json()
34 34 assert isinstance(data, dict)
35 assert data.has_key("name")
36 assert data.has_key("path")
35 self.assertIn('name', data)
36 self.assertIn('path', data)
37 37 self.assertEqual(data['name'], u'Untitled0.ipynb')
38 38 self.assertEqual(data['path'], u'/')
39 39
40 40 # GET list of notebooks in directory.
41 41 r = requests.get(url)
42 42 assert isinstance(r.json(), list)
43 43 assert isinstance(r.json()[0], dict)
44 44
45 45 self.delnb('Untitled0.ipynb')
46
47 def test_notebook_handler(self):
46
48 47 # GET with a notebook name.
49 48 url, nb = self.mknb()
50 49 data = nb.json()
51 50 url = self.notebook_url() + '/Untitled0.ipynb'
52 51 r = requests.get(url)
53 52 assert isinstance(data, dict)
54 53 self.assertEqual(r.json(), data)
55 54
56 55 # PATCH (rename) request.
57 56 new_name = {'name':'test.ipynb'}
58 57 r = requests.patch(url, data=jsonapi.dumps(new_name))
59 58 data = r.json()
60 59 assert isinstance(data, dict)
61 60
62 61 # make sure the patch worked.
63 62 new_url = self.notebook_url() + '/test.ipynb'
64 63 r = requests.get(new_url)
65 64 assert isinstance(r.json(), dict)
66 65 self.assertEqual(r.json(), data)
67 66
68 67 # GET bad (old) notebook name.
69 68 r = requests.get(url)
70 69 self.assertEqual(r.status_code, 404)
71 70
72 71 # POST notebooks to folders one and two levels down.
73 72 os.makedirs(os.path.join(self.notebook_dir.name, 'foo'))
74 73 os.makedirs(os.path.join(self.notebook_dir.name, 'foo','bar'))
75 74 assert os.path.isdir(os.path.join(self.notebook_dir.name, 'foo'))
76 75 url, nb = self.mknb(path='/foo/')
77 76 url2, nb2 = self.mknb(path='/foo/bar/')
78 77 data = nb.json()
79 78 data2 = nb2.json()
80 79 assert isinstance(data, dict)
81 80 assert isinstance(data2, dict)
82 assert data.has_key("name")
83 assert data.has_key("path")
81 self.assertIn('name', data)
82 self.assertIn('path', data)
84 83 self.assertEqual(data['name'], u'Untitled0.ipynb')
85 84 self.assertEqual(data['path'], u'/foo/')
86 assert data2.has_key("name")
87 assert data2.has_key("path")
85 self.assertIn('name', data2)
86 self.assertIn('path', data2)
88 87 self.assertEqual(data2['name'], u'Untitled0.ipynb')
89 88 self.assertEqual(data2['path'], u'/foo/bar/')
90 89
91 90 # GET request on notebooks one and two levels down.
92 r = requests.get(url+'Untitled0.ipynb')
93 r2 = requests.get(url2+'Untitled0.ipynb')
91 r = requests.get(url+'/Untitled0.ipynb')
92 r2 = requests.get(url2+'/Untitled0.ipynb')
94 93 assert isinstance(r.json(), dict)
95 94 self.assertEqual(r.json(), data)
96 95 assert isinstance(r2.json(), dict)
97 96 self.assertEqual(r2.json(), data2)
98 97
99 98 # PATCH notebooks that are one and two levels down.
100 99 new_name = {'name': 'testfoo.ipynb'}
101 r = requests.patch(url+'Untitled0.ipynb', data=jsonapi.dumps(new_name))
102 r = requests.get(url+'testfoo.ipynb')
100 r = requests.patch(url+'/Untitled0.ipynb', data=jsonapi.dumps(new_name))
101 r = requests.get(url+'/testfoo.ipynb')
103 102 data = r.json()
104 103 assert isinstance(data, dict)
105 assert data.has_key('name')
104 self.assertIn('name', data)
106 105 self.assertEqual(data['name'], 'testfoo.ipynb')
107 r = requests.get(url+'Untitled0.ipynb')
106 r = requests.get(url+'/Untitled0.ipynb')
108 107 self.assertEqual(r.status_code, 404)
109 108
110 109 # DELETE notebooks
111 110 r0 = self.delnb('test.ipynb')
112 111 r1 = self.delnb('testfoo.ipynb', '/foo/')
113 112 r2 = self.delnb('Untitled0.ipynb', '/foo/bar/')
114 113 self.assertEqual(r0, 204)
115 114 self.assertEqual(r1, 204)
116 115 self.assertEqual(r2, 204)
@@ -1,95 +1,95 b''
1 1 """Test the sessions web service API."""
2 2
3 3
4 4 import os
5 5 import sys
6 6 import json
7 7 from zmq.utils import jsonapi
8 8
9 9 import requests
10 10
11 11 from IPython.html.tests.launchnotebook import NotebookTestBase
12 12
13 13
14 14 class SessionAPITest(NotebookTestBase):
15 15 """Test the sessions web service API"""
16 16
17 17 def notebook_url(self):
18 18 return super(SessionAPITest,self).base_url() + 'api/notebooks'
19 19
20 20 def session_url(self):
21 21 return super(SessionAPITest,self).base_url() + 'api/sessions'
22 22
23 23 def mknb(self, name='', path='/'):
24 24 url = self.notebook_url() + path
25 25 return url, requests.post(url)
26 26
27 27 def delnb(self, name, path='/'):
28 28 url = self.notebook_url() + path + name
29 29 r = requests.delete(url)
30 30 return r.status_code
31 31
32 32 def test_no_sessions(self):
33 33 """Make sure there are no sessions running at the start"""
34 34 url = self.session_url()
35 35 r = requests.get(url)
36 36 self.assertEqual(r.json(), [])
37 37
38 38 def test_session_root_handler(self):
39 39 # POST a session
40 40 url, nb = self.mknb()
41 41 notebook = nb.json()
42 42 param = {'notebook_path': notebook['path'] + notebook['name']}
43 43 r = requests.post(self.session_url(), params=param)
44 44 data = r.json()
45 45 assert isinstance(data, dict)
46 assert data.has_key('name')
46 self.assertIn('name', data)
47 47 self.assertEqual(data['name'], notebook['name'])
48 48
49 49 # GET sessions
50 50 r = requests.get(self.session_url())
51 51 assert isinstance(r.json(), list)
52 52 assert isinstance(r.json()[0], dict)
53 53 self.assertEqual(r.json()[0]['id'], data['id'])
54 54
55 55 # Clean up
56 56 self.delnb('Untitled0.ipynb')
57 57 sess_url = self.session_url() +'/'+data['id']
58 58 r = requests.delete(sess_url)
59 59 self.assertEqual(r.status_code, 204)
60 60
61 61 def test_session_handler(self):
62 62 # Create a session
63 63 url, nb = self.mknb()
64 64 notebook = nb.json()
65 65 param = {'notebook_path': notebook['path'] + notebook['name']}
66 66 r = requests.post(self.session_url(), params=param)
67 67 session = r.json()
68 68
69 69 # GET a session
70 70 sess_url = self.session_url() + '/' + session['id']
71 71 r = requests.get(sess_url)
72 72 assert isinstance(r.json(), dict)
73 73 self.assertEqual(r.json(), session)
74 74
75 75 # PATCH a session
76 76 data = {'notebook_path': 'test.ipynb'}
77 77 r = requests.patch(sess_url, data=jsonapi.dumps(data))
78 78 # Patching the notebook webservice too (just for consistency)
79 79 requests.patch(self.notebook_url() + '/Untitled0.ipynb',
80 80 data=jsonapi.dumps({'name':'test.ipynb'}))
81 81 assert isinstance(r.json(), dict)
82 assert r.json().has_key('name')
83 assert r.json().has_key('id')
82 self.assertIn('name', r.json())
83 self.assertIn('id', r.json())
84 84 self.assertEqual(r.json()['name'], 'test.ipynb')
85 85 self.assertEqual(r.json()['id'], session['id'])
86 86
87 87 # DELETE a session
88 88 r = requests.delete(sess_url)
89 89 self.assertEqual(r.status_code, 204)
90 90 r = requests.get(self.session_url())
91 assert r.json() == []
91 self.assertEqual(r.json(), [])
92 92
93 93 # Clean up
94 94 r = self.delnb('test.ipynb')
95 assert r == 204 No newline at end of file
95 self.assertEqual(r, 204) No newline at end of file
@@ -1,41 +1,63 b''
1 1 """Base class for notebook tests."""
2 2
3 3 import sys
4 4 import time
5 import requests
5 6 from subprocess import Popen, PIPE
6 7 from unittest import TestCase
7 8
8 9 from IPython.utils.tempdir import TemporaryDirectory
9 10
10 11
11 12 class NotebookTestBase(TestCase):
12 13 """A base class for tests that need a running notebook.
13 14
14 15 This creates an empty profile in a temp ipython_dir
15 16 and then starts the notebook server with a separate temp notebook_dir.
16 17 """
17 18
18 19 port = 1234
19 20
21 def wait_till_alive(self):
22 url = 'http://localhost:%i/' % self.port
23 while True:
24 time.sleep(.1)
25 try:
26 r = requests.get(url + 'api/notebooks')
27 break
28 except requests.exceptions.ConnectionError:
29 pass
30
31 def wait_till_dead(self):
32 url = 'http://localhost:%i/' % self.port
33 while True:
34 time.sleep(.1)
35 try:
36 r = requests.get(url + 'api/notebooks')
37 continue
38 except requests.exceptions.ConnectionError:
39 break
40
20 41 def setUp(self):
21 42 self.ipython_dir = TemporaryDirectory()
22 43 self.notebook_dir = TemporaryDirectory()
23 44 notebook_args = [
24 45 sys.executable, '-c',
25 46 'from IPython.html.notebookapp import launch_new_instance; launch_new_instance()',
26 47 '--port=%d' % self.port,
27 48 '--no-browser',
28 49 '--ipython-dir=%s' % self.ipython_dir.name,
29 50 '--notebook-dir=%s' % self.notebook_dir.name
30 ]
51 ]
31 52 self.notebook = Popen(notebook_args, stdout=PIPE, stderr=PIPE)
32 time.sleep(3.0)
53 self.wait_till_alive()
54 #time.sleep(3.0)
33 55
34 56 def tearDown(self):
35 57 self.notebook.terminate()
36 58 self.ipython_dir.cleanup()
37 59 self.notebook_dir.cleanup()
38 time.sleep(3.0)
39
60 self.wait_till_dead()
61
40 62 def base_url(self):
41 63 return 'http://localhost:%i/' % self.port
General Comments 0
You need to be logged in to leave comments. Login now