##// END OF EJS Templates
Add test for and fix REST save with rename
Thomas Kluyver -
Show More
@@ -1,412 +1,412 b''
1 """A notebook manager that uses the local file system for storage.
1 """A notebook manager that uses the local file system for storage.
2
2
3 Authors:
3 Authors:
4
4
5 * Brian Granger
5 * Brian Granger
6 * Zach Sailer
6 * Zach Sailer
7 """
7 """
8
8
9 #-----------------------------------------------------------------------------
9 #-----------------------------------------------------------------------------
10 # Copyright (C) 2011 The IPython Development Team
10 # Copyright (C) 2011 The IPython Development Team
11 #
11 #
12 # Distributed under the terms of the BSD License. The full license is in
12 # Distributed under the terms of the BSD License. The full license is in
13 # the file COPYING, distributed as part of this software.
13 # the file COPYING, distributed as part of this software.
14 #-----------------------------------------------------------------------------
14 #-----------------------------------------------------------------------------
15
15
16 #-----------------------------------------------------------------------------
16 #-----------------------------------------------------------------------------
17 # Imports
17 # Imports
18 #-----------------------------------------------------------------------------
18 #-----------------------------------------------------------------------------
19
19
20 import datetime
20 import datetime
21 import io
21 import io
22 import os
22 import os
23 import glob
23 import glob
24 import shutil
24 import shutil
25
25
26 from unicodedata import normalize
26 from unicodedata import normalize
27
27
28 from tornado import web
28 from tornado import web
29
29
30 from .nbmanager import NotebookManager
30 from .nbmanager import NotebookManager
31 from IPython.nbformat import current
31 from IPython.nbformat import current
32 from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
32 from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
33 from IPython.utils import tz
33 from IPython.utils import tz
34
34
35 #-----------------------------------------------------------------------------
35 #-----------------------------------------------------------------------------
36 # Classes
36 # Classes
37 #-----------------------------------------------------------------------------
37 #-----------------------------------------------------------------------------
38
38
39 class FileNotebookManager(NotebookManager):
39 class FileNotebookManager(NotebookManager):
40
40
41 save_script = Bool(False, config=True,
41 save_script = Bool(False, config=True,
42 help="""Automatically create a Python script when saving the notebook.
42 help="""Automatically create a Python script when saving the notebook.
43
43
44 For easier use of import, %run and %load across notebooks, a
44 For easier use of import, %run and %load across notebooks, a
45 <notebook-name>.py script will be created next to any
45 <notebook-name>.py script will be created next to any
46 <notebook-name>.ipynb on each save. This can also be set with the
46 <notebook-name>.ipynb on each save. This can also be set with the
47 short `--script` flag.
47 short `--script` flag.
48 """
48 """
49 )
49 )
50
50
51 checkpoint_dir = Unicode(config=True,
51 checkpoint_dir = Unicode(config=True,
52 help="""The location in which to keep notebook checkpoints
52 help="""The location in which to keep notebook checkpoints
53
53
54 By default, it is notebook-dir/.ipynb_checkpoints
54 By default, it is notebook-dir/.ipynb_checkpoints
55 """
55 """
56 )
56 )
57 def _checkpoint_dir_default(self):
57 def _checkpoint_dir_default(self):
58 return os.path.join(self.notebook_dir, '.ipynb_checkpoints')
58 return os.path.join(self.notebook_dir, '.ipynb_checkpoints')
59
59
60 def _checkpoint_dir_changed(self, name, old, new):
60 def _checkpoint_dir_changed(self, name, old, new):
61 """do a bit of validation of the checkpoint dir"""
61 """do a bit of validation of the checkpoint dir"""
62 if not os.path.isabs(new):
62 if not os.path.isabs(new):
63 # If we receive a non-absolute path, make it absolute.
63 # If we receive a non-absolute path, make it absolute.
64 abs_new = os.path.abspath(new)
64 abs_new = os.path.abspath(new)
65 self.checkpoint_dir = abs_new
65 self.checkpoint_dir = abs_new
66 return
66 return
67 if os.path.exists(new) and not os.path.isdir(new):
67 if os.path.exists(new) and not os.path.isdir(new):
68 raise TraitError("checkpoint dir %r is not a directory" % new)
68 raise TraitError("checkpoint dir %r is not a directory" % new)
69 if not os.path.exists(new):
69 if not os.path.exists(new):
70 self.log.info("Creating checkpoint dir %s", new)
70 self.log.info("Creating checkpoint dir %s", new)
71 try:
71 try:
72 os.mkdir(new)
72 os.mkdir(new)
73 except:
73 except:
74 raise TraitError("Couldn't create checkpoint dir %r" % new)
74 raise TraitError("Couldn't create checkpoint dir %r" % new)
75
75
76 def get_notebook_names(self, path=''):
76 def get_notebook_names(self, path=''):
77 """List all notebook names in the notebook dir and path."""
77 """List all notebook names in the notebook dir and path."""
78 path = path.strip('/')
78 path = path.strip('/')
79 names = glob.glob(self.get_os_path('*'+self.filename_ext, path))
79 names = glob.glob(self.get_os_path('*'+self.filename_ext, path))
80 names = [os.path.basename(name)
80 names = [os.path.basename(name)
81 for name in names]
81 for name in names]
82 return names
82 return names
83
83
84 def increment_filename(self, basename, path=''):
84 def increment_filename(self, basename, path=''):
85 """Return a non-used filename of the form basename<int>."""
85 """Return a non-used filename of the form basename<int>."""
86 path = path.strip('/')
86 path = path.strip('/')
87 i = 0
87 i = 0
88 while True:
88 while True:
89 name = u'%s%i.ipynb' % (basename,i)
89 name = u'%s%i.ipynb' % (basename,i)
90 os_path = self.get_os_path(name, path)
90 os_path = self.get_os_path(name, path)
91 if not os.path.isfile(os_path):
91 if not os.path.isfile(os_path):
92 break
92 break
93 else:
93 else:
94 i = i+1
94 i = i+1
95 return name
95 return name
96
96
97 def path_exists(self, path):
97 def path_exists(self, path):
98 """Does the API-style path (directory) actually exist?
98 """Does the API-style path (directory) actually exist?
99
99
100 Parameters
100 Parameters
101 ----------
101 ----------
102 path : string
102 path : string
103 The path to check. This is an API path (`/` separated,
103 The path to check. This is an API path (`/` separated,
104 relative to base notebook-dir).
104 relative to base notebook-dir).
105
105
106 Returns
106 Returns
107 -------
107 -------
108 exists : bool
108 exists : bool
109 Whether the path is indeed a directory.
109 Whether the path is indeed a directory.
110 """
110 """
111 path = path.strip('/')
111 path = path.strip('/')
112 os_path = self.get_os_path(path=path)
112 os_path = self.get_os_path(path=path)
113 return os.path.isdir(os_path)
113 return os.path.isdir(os_path)
114
114
115 def get_os_path(self, name=None, path=''):
115 def get_os_path(self, name=None, path=''):
116 """Given a notebook name and a URL path, return its file system
116 """Given a notebook name and a URL path, return its file system
117 path.
117 path.
118
118
119 Parameters
119 Parameters
120 ----------
120 ----------
121 name : string
121 name : string
122 The name of a notebook file with the .ipynb extension
122 The name of a notebook file with the .ipynb extension
123 path : string
123 path : string
124 The relative URL path (with '/' as separator) to the named
124 The relative URL path (with '/' as separator) to the named
125 notebook.
125 notebook.
126
126
127 Returns
127 Returns
128 -------
128 -------
129 path : string
129 path : string
130 A file system path that combines notebook_dir (location where
130 A file system path that combines notebook_dir (location where
131 server started), the relative path, and the filename with the
131 server started), the relative path, and the filename with the
132 current operating system's url.
132 current operating system's url.
133 """
133 """
134 parts = path.strip('/').split('/')
134 parts = path.strip('/').split('/')
135 parts = [p for p in parts if p != ''] # remove duplicate splits
135 parts = [p for p in parts if p != ''] # remove duplicate splits
136 if name is not None:
136 if name is not None:
137 parts.append(name)
137 parts.append(name)
138 path = os.path.join(self.notebook_dir, *parts)
138 path = os.path.join(self.notebook_dir, *parts)
139 return path
139 return path
140
140
141 def notebook_exists(self, name, path=''):
141 def notebook_exists(self, name, path=''):
142 """Returns a True if the notebook exists. Else, returns False.
142 """Returns a True if the notebook exists. Else, returns False.
143
143
144 Parameters
144 Parameters
145 ----------
145 ----------
146 name : string
146 name : string
147 The name of the notebook you are checking.
147 The name of the notebook you are checking.
148 path : string
148 path : string
149 The relative path to the notebook (with '/' as separator)
149 The relative path to the notebook (with '/' as separator)
150
150
151 Returns
151 Returns
152 -------
152 -------
153 bool
153 bool
154 """
154 """
155 path = path.strip('/')
155 path = path.strip('/')
156 nbpath = self.get_os_path(name, path=path)
156 nbpath = self.get_os_path(name, path=path)
157 return os.path.isfile(nbpath)
157 return os.path.isfile(nbpath)
158
158
159 def list_notebooks(self, path):
159 def list_notebooks(self, path):
160 """Returns a list of dictionaries that are the standard model
160 """Returns a list of dictionaries that are the standard model
161 for all notebooks in the relative 'path'.
161 for all notebooks in the relative 'path'.
162
162
163 Parameters
163 Parameters
164 ----------
164 ----------
165 path : str
165 path : str
166 the URL path that describes the relative path for the
166 the URL path that describes the relative path for the
167 listed notebooks
167 listed notebooks
168
168
169 Returns
169 Returns
170 -------
170 -------
171 notebooks : list of dicts
171 notebooks : list of dicts
172 a list of the notebook models without 'content'
172 a list of the notebook models without 'content'
173 """
173 """
174 path = path.strip('/')
174 path = path.strip('/')
175 notebook_names = self.get_notebook_names(path)
175 notebook_names = self.get_notebook_names(path)
176 notebooks = []
176 notebooks = []
177 for name in notebook_names:
177 for name in notebook_names:
178 model = self.get_notebook_model(name, path, content=False)
178 model = self.get_notebook_model(name, path, content=False)
179 notebooks.append(model)
179 notebooks.append(model)
180 notebooks = sorted(notebooks, key=lambda item: item['name'])
180 notebooks = sorted(notebooks, key=lambda item: item['name'])
181 return notebooks
181 return notebooks
182
182
183 def get_notebook_model(self, name, path='', content=True):
183 def get_notebook_model(self, name, path='', content=True):
184 """ Takes a path and name for a notebook and returns it's model
184 """ Takes a path and name for a notebook and returns it's model
185
185
186 Parameters
186 Parameters
187 ----------
187 ----------
188 name : str
188 name : str
189 the name of the notebook
189 the name of the notebook
190 path : str
190 path : str
191 the URL path that describes the relative path for
191 the URL path that describes the relative path for
192 the notebook
192 the notebook
193
193
194 Returns
194 Returns
195 -------
195 -------
196 model : dict
196 model : dict
197 the notebook model. If contents=True, returns the 'contents'
197 the notebook model. If contents=True, returns the 'contents'
198 dict in the model as well.
198 dict in the model as well.
199 """
199 """
200 path = path.strip('/')
200 path = path.strip('/')
201 if not self.notebook_exists(name=name, path=path):
201 if not self.notebook_exists(name=name, path=path):
202 raise web.HTTPError(404, u'Notebook does not exist: %s' % name)
202 raise web.HTTPError(404, u'Notebook does not exist: %s' % name)
203 os_path = self.get_os_path(name, path)
203 os_path = self.get_os_path(name, path)
204 info = os.stat(os_path)
204 info = os.stat(os_path)
205 last_modified = tz.utcfromtimestamp(info.st_mtime)
205 last_modified = tz.utcfromtimestamp(info.st_mtime)
206 created = tz.utcfromtimestamp(info.st_ctime)
206 created = tz.utcfromtimestamp(info.st_ctime)
207 # Create the notebook model.
207 # Create the notebook model.
208 model ={}
208 model ={}
209 model['name'] = name
209 model['name'] = name
210 model['path'] = path
210 model['path'] = path
211 model['last_modified'] = last_modified
211 model['last_modified'] = last_modified
212 model['created'] = last_modified
212 model['created'] = last_modified
213 if content is True:
213 if content is True:
214 with open(os_path, 'r') as f:
214 with open(os_path, 'r') as f:
215 try:
215 try:
216 nb = current.read(f, u'json')
216 nb = current.read(f, u'json')
217 except Exception as e:
217 except Exception as e:
218 raise web.HTTPError(400, u"Unreadable Notebook: %s %s" % (os_path, e))
218 raise web.HTTPError(400, u"Unreadable Notebook: %s %s" % (os_path, e))
219 model['content'] = nb
219 model['content'] = nb
220 return model
220 return model
221
221
222 def save_notebook_model(self, model, name='', path=''):
222 def save_notebook_model(self, model, name='', path=''):
223 """Save the notebook model and return the model with no content."""
223 """Save the notebook model and return the model with no content."""
224 path = path.strip('/')
224 path = path.strip('/')
225
225
226 if 'content' not in model:
226 if 'content' not in model:
227 raise web.HTTPError(400, u'No notebook JSON data provided')
227 raise web.HTTPError(400, u'No notebook JSON data provided')
228
228
229 new_path = model.get('path', path).strip('/')
229 new_path = model.get('path', path).strip('/')
230 new_name = model.get('name', name)
230 new_name = model.get('name', name)
231
231
232 if path != new_path or name != new_name:
232 if path != new_path or name != new_name:
233 self.rename_notebook(name, path, new_name, new_path)
233 self.rename_notebook(name, path, new_name, new_path)
234
234
235 # Save the notebook file
235 # Save the notebook file
236 os_path = self.get_os_path(new_name, new_path)
236 os_path = self.get_os_path(new_name, new_path)
237 nb = current.to_notebook_json(model['content'])
237 nb = current.to_notebook_json(model['content'])
238 if 'name' in nb['metadata']:
238 if 'name' in nb['metadata']:
239 nb['metadata']['name'] = u''
239 nb['metadata']['name'] = u''
240 try:
240 try:
241 self.log.debug("Autosaving notebook %s", os_path)
241 self.log.debug("Autosaving notebook %s", os_path)
242 with open(os_path, 'w') as f:
242 with open(os_path, 'w') as f:
243 current.write(nb, f, u'json')
243 current.write(nb, f, u'json')
244 except Exception as e:
244 except Exception as e:
245 raise web.HTTPError(400, u'Unexpected error while autosaving notebook: %s %s' % (os_path, e))
245 raise web.HTTPError(400, u'Unexpected error while autosaving notebook: %s %s' % (os_path, e))
246
246
247 # Save .py script as well
247 # Save .py script as well
248 if self.save_script:
248 if self.save_script:
249 py_path = os.path.splitext(os_path)[0] + '.py'
249 py_path = os.path.splitext(os_path)[0] + '.py'
250 self.log.debug("Writing script %s", py_path)
250 self.log.debug("Writing script %s", py_path)
251 try:
251 try:
252 with io.open(py_path, 'w', encoding='utf-8') as f:
252 with io.open(py_path, 'w', encoding='utf-8') as f:
253 current.write(model, f, u'py')
253 current.write(model, f, u'py')
254 except Exception as e:
254 except Exception as e:
255 raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s %s' % (py_path, e))
255 raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s %s' % (py_path, e))
256
256
257 model = self.get_notebook_model(name, path, content=False)
257 model = self.get_notebook_model(new_name, new_path, content=False)
258 return model
258 return model
259
259
260 def update_notebook_model(self, model, name, path=''):
260 def update_notebook_model(self, model, name, path=''):
261 """Update the notebook's path and/or name"""
261 """Update the notebook's path and/or name"""
262 path = path.strip('/')
262 path = path.strip('/')
263 new_name = model.get('name', name)
263 new_name = model.get('name', name)
264 new_path = model.get('path', path).strip('/')
264 new_path = model.get('path', path).strip('/')
265 if path != new_path or name != new_name:
265 if path != new_path or name != new_name:
266 self.rename_notebook(name, path, new_name, new_path)
266 self.rename_notebook(name, path, new_name, new_path)
267 model = self.get_notebook_model(new_name, new_path, content=False)
267 model = self.get_notebook_model(new_name, new_path, content=False)
268 return model
268 return model
269
269
270 def delete_notebook_model(self, name, path=''):
270 def delete_notebook_model(self, name, path=''):
271 """Delete notebook by name and path."""
271 """Delete notebook by name and path."""
272 path = path.strip('/')
272 path = path.strip('/')
273 os_path = self.get_os_path(name, path)
273 os_path = self.get_os_path(name, path)
274 if not os.path.isfile(os_path):
274 if not os.path.isfile(os_path):
275 raise web.HTTPError(404, u'Notebook does not exist: %s' % os_path)
275 raise web.HTTPError(404, u'Notebook does not exist: %s' % os_path)
276
276
277 # clear checkpoints
277 # clear checkpoints
278 for checkpoint in self.list_checkpoints(name, path):
278 for checkpoint in self.list_checkpoints(name, path):
279 checkpoint_id = checkpoint['checkpoint_id']
279 checkpoint_id = checkpoint['checkpoint_id']
280 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
280 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
281 if os.path.isfile(cp_path):
281 if os.path.isfile(cp_path):
282 self.log.debug("Unlinking checkpoint %s", cp_path)
282 self.log.debug("Unlinking checkpoint %s", cp_path)
283 os.unlink(cp_path)
283 os.unlink(cp_path)
284
284
285 self.log.debug("Unlinking notebook %s", os_path)
285 self.log.debug("Unlinking notebook %s", os_path)
286 os.unlink(os_path)
286 os.unlink(os_path)
287
287
288 def rename_notebook(self, old_name, old_path, new_name, new_path):
288 def rename_notebook(self, old_name, old_path, new_name, new_path):
289 """Rename a notebook."""
289 """Rename a notebook."""
290 old_path = old_path.strip('/')
290 old_path = old_path.strip('/')
291 new_path = new_path.strip('/')
291 new_path = new_path.strip('/')
292 if new_name == old_name and new_path == old_path:
292 if new_name == old_name and new_path == old_path:
293 return
293 return
294
294
295 new_os_path = self.get_os_path(new_name, new_path)
295 new_os_path = self.get_os_path(new_name, new_path)
296 old_os_path = self.get_os_path(old_name, old_path)
296 old_os_path = self.get_os_path(old_name, old_path)
297
297
298 # Should we proceed with the move?
298 # Should we proceed with the move?
299 if os.path.isfile(new_os_path):
299 if os.path.isfile(new_os_path):
300 raise web.HTTPError(409, u'Notebook with name already exists: %s' % new_os_path)
300 raise web.HTTPError(409, u'Notebook with name already exists: %s' % new_os_path)
301 if self.save_script:
301 if self.save_script:
302 old_py_path = os.path.splitext(old_os_path)[0] + '.py'
302 old_py_path = os.path.splitext(old_os_path)[0] + '.py'
303 new_py_path = os.path.splitext(new_os_path)[0] + '.py'
303 new_py_path = os.path.splitext(new_os_path)[0] + '.py'
304 if os.path.isfile(new_py_path):
304 if os.path.isfile(new_py_path):
305 raise web.HTTPError(409, u'Python script with name already exists: %s' % new_py_path)
305 raise web.HTTPError(409, u'Python script with name already exists: %s' % new_py_path)
306
306
307 # Move the notebook file
307 # Move the notebook file
308 try:
308 try:
309 os.rename(old_os_path, new_os_path)
309 os.rename(old_os_path, new_os_path)
310 except Exception as e:
310 except Exception as e:
311 raise web.HTTPError(500, u'Unknown error renaming notebook: %s %s' % (old_os_path, e))
311 raise web.HTTPError(500, u'Unknown error renaming notebook: %s %s' % (old_os_path, e))
312
312
313 # Move the checkpoints
313 # Move the checkpoints
314 old_checkpoints = self.list_checkpoints(old_name, old_path)
314 old_checkpoints = self.list_checkpoints(old_name, old_path)
315 for cp in old_checkpoints:
315 for cp in old_checkpoints:
316 checkpoint_id = cp['checkpoint_id']
316 checkpoint_id = cp['checkpoint_id']
317 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_name, old_path)
317 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_name, old_path)
318 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_name, new_path)
318 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_name, new_path)
319 if os.path.isfile(old_cp_path):
319 if os.path.isfile(old_cp_path):
320 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
320 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
321 os.rename(old_cp_path, new_cp_path)
321 os.rename(old_cp_path, new_cp_path)
322
322
323 # Move the .py script
323 # Move the .py script
324 if self.save_script:
324 if self.save_script:
325 os.rename(old_py_path, new_py_path)
325 os.rename(old_py_path, new_py_path)
326
326
327 # Checkpoint-related utilities
327 # Checkpoint-related utilities
328
328
329 def get_checkpoint_path(self, checkpoint_id, name, path=''):
329 def get_checkpoint_path(self, checkpoint_id, name, path=''):
330 """find the path to a checkpoint"""
330 """find the path to a checkpoint"""
331 path = path.strip('/')
331 path = path.strip('/')
332 filename = u"{name}-{checkpoint_id}{ext}".format(
332 filename = u"{name}-{checkpoint_id}{ext}".format(
333 name=name,
333 name=name,
334 checkpoint_id=checkpoint_id,
334 checkpoint_id=checkpoint_id,
335 ext=self.filename_ext,
335 ext=self.filename_ext,
336 )
336 )
337 cp_path = os.path.join(path, self.checkpoint_dir, filename)
337 cp_path = os.path.join(path, self.checkpoint_dir, filename)
338 return cp_path
338 return cp_path
339
339
340 def get_checkpoint_model(self, checkpoint_id, name, path=''):
340 def get_checkpoint_model(self, checkpoint_id, name, path=''):
341 """construct the info dict for a given checkpoint"""
341 """construct the info dict for a given checkpoint"""
342 path = path.strip('/')
342 path = path.strip('/')
343 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
343 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
344 stats = os.stat(cp_path)
344 stats = os.stat(cp_path)
345 last_modified = tz.utcfromtimestamp(stats.st_mtime)
345 last_modified = tz.utcfromtimestamp(stats.st_mtime)
346 info = dict(
346 info = dict(
347 checkpoint_id = checkpoint_id,
347 checkpoint_id = checkpoint_id,
348 last_modified = last_modified,
348 last_modified = last_modified,
349 )
349 )
350 return info
350 return info
351
351
352 # public checkpoint API
352 # public checkpoint API
353
353
354 def create_checkpoint(self, name, path=''):
354 def create_checkpoint(self, name, path=''):
355 """Create a checkpoint from the current state of a notebook"""
355 """Create a checkpoint from the current state of a notebook"""
356 path = path.strip('/')
356 path = path.strip('/')
357 nb_path = self.get_os_path(name, path)
357 nb_path = self.get_os_path(name, path)
358 # only the one checkpoint ID:
358 # only the one checkpoint ID:
359 checkpoint_id = u"checkpoint"
359 checkpoint_id = u"checkpoint"
360 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
360 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
361 self.log.debug("creating checkpoint for notebook %s", name)
361 self.log.debug("creating checkpoint for notebook %s", name)
362 if not os.path.exists(self.checkpoint_dir):
362 if not os.path.exists(self.checkpoint_dir):
363 os.mkdir(self.checkpoint_dir)
363 os.mkdir(self.checkpoint_dir)
364 shutil.copy2(nb_path, cp_path)
364 shutil.copy2(nb_path, cp_path)
365
365
366 # return the checkpoint info
366 # return the checkpoint info
367 return self.get_checkpoint_model(checkpoint_id, name, path)
367 return self.get_checkpoint_model(checkpoint_id, name, path)
368
368
369 def list_checkpoints(self, name, path=''):
369 def list_checkpoints(self, name, path=''):
370 """list the checkpoints for a given notebook
370 """list the checkpoints for a given notebook
371
371
372 This notebook manager currently only supports one checkpoint per notebook.
372 This notebook manager currently only supports one checkpoint per notebook.
373 """
373 """
374 path = path.strip('/')
374 path = path.strip('/')
375 checkpoint_id = "checkpoint"
375 checkpoint_id = "checkpoint"
376 path = self.get_checkpoint_path(checkpoint_id, name, path)
376 path = self.get_checkpoint_path(checkpoint_id, name, path)
377 if not os.path.exists(path):
377 if not os.path.exists(path):
378 return []
378 return []
379 else:
379 else:
380 return [self.get_checkpoint_model(checkpoint_id, name, path)]
380 return [self.get_checkpoint_model(checkpoint_id, name, path)]
381
381
382
382
383 def restore_checkpoint(self, checkpoint_id, name, path=''):
383 def restore_checkpoint(self, checkpoint_id, name, path=''):
384 """restore a notebook to a checkpointed state"""
384 """restore a notebook to a checkpointed state"""
385 path = path.strip('/')
385 path = path.strip('/')
386 self.log.info("restoring Notebook %s from checkpoint %s", name, checkpoint_id)
386 self.log.info("restoring Notebook %s from checkpoint %s", name, checkpoint_id)
387 nb_path = self.get_os_path(name, path)
387 nb_path = self.get_os_path(name, path)
388 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
388 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
389 if not os.path.isfile(cp_path):
389 if not os.path.isfile(cp_path):
390 self.log.debug("checkpoint file does not exist: %s", cp_path)
390 self.log.debug("checkpoint file does not exist: %s", cp_path)
391 raise web.HTTPError(404,
391 raise web.HTTPError(404,
392 u'Notebook checkpoint does not exist: %s-%s' % (name, checkpoint_id)
392 u'Notebook checkpoint does not exist: %s-%s' % (name, checkpoint_id)
393 )
393 )
394 # ensure notebook is readable (never restore from an unreadable notebook)
394 # ensure notebook is readable (never restore from an unreadable notebook)
395 with file(cp_path, 'r') as f:
395 with file(cp_path, 'r') as f:
396 nb = current.read(f, u'json')
396 nb = current.read(f, u'json')
397 shutil.copy2(cp_path, nb_path)
397 shutil.copy2(cp_path, nb_path)
398 self.log.debug("copying %s -> %s", cp_path, nb_path)
398 self.log.debug("copying %s -> %s", cp_path, nb_path)
399
399
400 def delete_checkpoint(self, checkpoint_id, name, path=''):
400 def delete_checkpoint(self, checkpoint_id, name, path=''):
401 """delete a notebook's checkpoint"""
401 """delete a notebook's checkpoint"""
402 path = path.strip('/')
402 path = path.strip('/')
403 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
403 cp_path = self.get_checkpoint_path(checkpoint_id, name, path)
404 if not os.path.isfile(cp_path):
404 if not os.path.isfile(cp_path):
405 raise web.HTTPError(404,
405 raise web.HTTPError(404,
406 u'Notebook checkpoint does not exist: %s%s-%s' % (path, name, checkpoint_id)
406 u'Notebook checkpoint does not exist: %s%s-%s' % (path, name, checkpoint_id)
407 )
407 )
408 self.log.debug("unlinking %s", cp_path)
408 self.log.debug("unlinking %s", cp_path)
409 os.unlink(cp_path)
409 os.unlink(cp_path)
410
410
411 def info_string(self):
411 def info_string(self):
412 return "Serving notebooks from local directory: %s" % self.notebook_dir
412 return "Serving notebooks from local directory: %s" % self.notebook_dir
@@ -1,200 +1,213 b''
1 """Test the notebooks webservice API."""
1 """Test the notebooks webservice API."""
2
2
3 import io
3 import io
4 import os
4 import os
5 import shutil
5 import shutil
6 from zmq.utils import jsonapi
6 from zmq.utils import jsonapi
7
7
8 pjoin = os.path.join
8 pjoin = os.path.join
9
9
10 import requests
10 import requests
11
11
12 from IPython.html.utils import url_path_join
12 from IPython.html.utils import url_path_join
13 from IPython.html.tests.launchnotebook import NotebookTestBase
13 from IPython.html.tests.launchnotebook import NotebookTestBase
14 from IPython.nbformat.current import (new_notebook, write, read, new_worksheet,
14 from IPython.nbformat.current import (new_notebook, write, read, new_worksheet,
15 new_heading_cell, to_notebook_json)
15 new_heading_cell, to_notebook_json)
16 from IPython.utils.data import uniq_stable
16 from IPython.utils.data import uniq_stable
17
17
18 class NBAPI(object):
18 class NBAPI(object):
19 """Wrapper for notebook API calls."""
19 """Wrapper for notebook API calls."""
20 def __init__(self, base_url):
20 def __init__(self, base_url):
21 self.base_url = base_url
21 self.base_url = base_url
22
22
23 @property
23 @property
24 def nb_url(self):
24 def nb_url(self):
25 return url_path_join(self.base_url, 'api/notebooks')
25 return url_path_join(self.base_url, 'api/notebooks')
26
26
27 def _req(self, verb, path, body=None):
27 def _req(self, verb, path, body=None):
28 response = requests.request(verb,
28 response = requests.request(verb,
29 url_path_join(self.base_url, 'api/notebooks', path), data=body)
29 url_path_join(self.base_url, 'api/notebooks', path), data=body)
30 response.raise_for_status()
30 response.raise_for_status()
31 return response
31 return response
32
32
33 def list(self, path='/'):
33 def list(self, path='/'):
34 return self._req('GET', path)
34 return self._req('GET', path)
35
35
36 def read(self, name, path='/'):
36 def read(self, name, path='/'):
37 return self._req('GET', url_path_join(path, name))
37 return self._req('GET', url_path_join(path, name))
38
38
39 def create_untitled(self, path='/'):
39 def create_untitled(self, path='/'):
40 return self._req('POST', path)
40 return self._req('POST', path)
41
41
42 def upload(self, name, body, path='/'):
42 def upload(self, name, body, path='/'):
43 return self._req('POST', url_path_join(path, name), body)
43 return self._req('POST', url_path_join(path, name), body)
44
44
45 def copy(self, name, path='/'):
45 def copy(self, name, path='/'):
46 return self._req('POST', url_path_join(path, name, 'copy'))
46 return self._req('POST', url_path_join(path, name, 'copy'))
47
47
48 def save(self, name, body, path='/'):
48 def save(self, name, body, path='/'):
49 return self._req('PUT', url_path_join(path, name), body)
49 return self._req('PUT', url_path_join(path, name), body)
50
50
51 def delete(self, name, path='/'):
51 def delete(self, name, path='/'):
52 return self._req('DELETE', url_path_join(path, name))
52 return self._req('DELETE', url_path_join(path, name))
53
53
54 def rename(self, name, path, new_name):
54 def rename(self, name, path, new_name):
55 body = jsonapi.dumps({'name': new_name})
55 body = jsonapi.dumps({'name': new_name})
56 return self._req('PATCH', url_path_join(path, name), body)
56 return self._req('PATCH', url_path_join(path, name), body)
57
57
58 class APITest(NotebookTestBase):
58 class APITest(NotebookTestBase):
59 """Test the kernels web service API"""
59 """Test the kernels web service API"""
60 dirs_nbs = [('', 'inroot'),
60 dirs_nbs = [('', 'inroot'),
61 ('Directory with spaces in', 'inspace'),
61 ('Directory with spaces in', 'inspace'),
62 (u'unicodΓ©', 'innonascii'),
62 (u'unicodΓ©', 'innonascii'),
63 ('foo', 'a'),
63 ('foo', 'a'),
64 ('foo', 'b'),
64 ('foo', 'b'),
65 ('foo', 'name with spaces'),
65 ('foo', 'name with spaces'),
66 ('foo', u'unicodΓ©'),
66 ('foo', u'unicodΓ©'),
67 ('foo/bar', 'baz'),
67 ('foo/bar', 'baz'),
68 ]
68 ]
69
69
70 dirs = uniq_stable([d for (d,n) in dirs_nbs])
70 dirs = uniq_stable([d for (d,n) in dirs_nbs])
71 del dirs[0] # remove ''
71 del dirs[0] # remove ''
72
72
73 def setUp(self):
73 def setUp(self):
74 nbdir = self.notebook_dir.name
74 nbdir = self.notebook_dir.name
75
75
76 for d in self.dirs:
76 for d in self.dirs:
77 os.mkdir(pjoin(nbdir, d))
77 os.mkdir(pjoin(nbdir, d))
78
78
79 for d, name in self.dirs_nbs:
79 for d, name in self.dirs_nbs:
80 with io.open(pjoin(nbdir, d, '%s.ipynb' % name), 'w') as f:
80 with io.open(pjoin(nbdir, d, '%s.ipynb' % name), 'w') as f:
81 nb = new_notebook(name=name)
81 nb = new_notebook(name=name)
82 write(nb, f, format='ipynb')
82 write(nb, f, format='ipynb')
83
83
84 self.nb_api = NBAPI(self.base_url())
84 self.nb_api = NBAPI(self.base_url())
85
85
86 def tearDown(self):
86 def tearDown(self):
87 nbdir = self.notebook_dir.name
87 nbdir = self.notebook_dir.name
88
88
89 for dname in ['foo', 'Directory with spaces in', u'unicodΓ©']:
89 for dname in ['foo', 'Directory with spaces in', u'unicodΓ©']:
90 shutil.rmtree(pjoin(nbdir, dname), ignore_errors=True)
90 shutil.rmtree(pjoin(nbdir, dname), ignore_errors=True)
91
91
92 if os.path.isfile(pjoin(nbdir, 'inroot.ipynb')):
92 if os.path.isfile(pjoin(nbdir, 'inroot.ipynb')):
93 os.unlink(pjoin(nbdir, 'inroot.ipynb'))
93 os.unlink(pjoin(nbdir, 'inroot.ipynb'))
94
94
95 def test_list_notebooks(self):
95 def test_list_notebooks(self):
96 nbs = self.nb_api.list().json()
96 nbs = self.nb_api.list().json()
97 self.assertEqual(len(nbs), 1)
97 self.assertEqual(len(nbs), 1)
98 self.assertEqual(nbs[0]['name'], 'inroot.ipynb')
98 self.assertEqual(nbs[0]['name'], 'inroot.ipynb')
99
99
100 nbs = self.nb_api.list('/Directory with spaces in/').json()
100 nbs = self.nb_api.list('/Directory with spaces in/').json()
101 self.assertEqual(len(nbs), 1)
101 self.assertEqual(len(nbs), 1)
102 self.assertEqual(nbs[0]['name'], 'inspace.ipynb')
102 self.assertEqual(nbs[0]['name'], 'inspace.ipynb')
103
103
104 nbs = self.nb_api.list(u'/unicodΓ©/').json()
104 nbs = self.nb_api.list(u'/unicodΓ©/').json()
105 self.assertEqual(len(nbs), 1)
105 self.assertEqual(len(nbs), 1)
106 self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
106 self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
107
107
108 nbs = self.nb_api.list('/foo/bar/').json()
108 nbs = self.nb_api.list('/foo/bar/').json()
109 self.assertEqual(len(nbs), 1)
109 self.assertEqual(len(nbs), 1)
110 self.assertEqual(nbs[0]['name'], 'baz.ipynb')
110 self.assertEqual(nbs[0]['name'], 'baz.ipynb')
111
111
112 nbs = self.nb_api.list('foo').json()
112 nbs = self.nb_api.list('foo').json()
113 self.assertEqual(len(nbs), 4)
113 self.assertEqual(len(nbs), 4)
114 nbnames = set(n['name'] for n in nbs)
114 nbnames = set(n['name'] for n in nbs)
115 self.assertEqual(nbnames, {'a.ipynb', 'b.ipynb',
115 self.assertEqual(nbnames, {'a.ipynb', 'b.ipynb',
116 'name with spaces.ipynb', u'unicodΓ©.ipynb'})
116 'name with spaces.ipynb', u'unicodΓ©.ipynb'})
117
117
118 def assert_404(self, name, path):
119 try:
120 self.nb_api.read(name, path)
121 except requests.HTTPError as e:
122 self.assertEqual(e.response.status_code, 404)
123 else:
124 assert False, "Reading a non-existent notebook should fail"
125
118 def test_get_contents(self):
126 def test_get_contents(self):
119 for d, name in self.dirs_nbs:
127 for d, name in self.dirs_nbs:
120 nb = self.nb_api.read('%s.ipynb' % name, d+'/').json()
128 nb = self.nb_api.read('%s.ipynb' % name, d+'/').json()
121 self.assertEqual(nb['name'], '%s.ipynb' % name)
129 self.assertEqual(nb['name'], '%s.ipynb' % name)
122 self.assertIn('content', nb)
130 self.assertIn('content', nb)
123 self.assertIn('metadata', nb['content'])
131 self.assertIn('metadata', nb['content'])
124 self.assertIsInstance(nb['content']['metadata'], dict)
132 self.assertIsInstance(nb['content']['metadata'], dict)
125
133
126 # Name that doesn't exist - should be a 404
134 # Name that doesn't exist - should be a 404
127 try:
135 self.assert_404('q.ipynb', 'foo')
128 self.nb_api.read('q.ipynb', 'foo')
129 except requests.HTTPError as e:
130 self.assertEqual(e.response.status_code, 404)
131 else:
132 assert False, "Reading a non-existent notebook should fail"
133
136
134 def _check_nb_created(self, resp, name, path):
137 def _check_nb_created(self, resp, name, path):
135 self.assertEqual(resp.status_code, 201)
138 self.assertEqual(resp.status_code, 201)
136 self.assertEqual(resp.headers['Location'].split('/')[-1], name)
139 self.assertEqual(resp.headers['Location'].split('/')[-1], name)
137 self.assertEqual(resp.json()['name'], name)
140 self.assertEqual(resp.json()['name'], name)
138 assert os.path.isfile(pjoin(self.notebook_dir.name, path, name))
141 assert os.path.isfile(pjoin(self.notebook_dir.name, path, name))
139
142
140 def test_create_untitled(self):
143 def test_create_untitled(self):
141 resp = self.nb_api.create_untitled(path='foo')
144 resp = self.nb_api.create_untitled(path='foo')
142 self._check_nb_created(resp, 'Untitled0.ipynb', 'foo')
145 self._check_nb_created(resp, 'Untitled0.ipynb', 'foo')
143
146
144 # Second time
147 # Second time
145 resp = self.nb_api.create_untitled(path='foo')
148 resp = self.nb_api.create_untitled(path='foo')
146 self._check_nb_created(resp, 'Untitled1.ipynb', 'foo')
149 self._check_nb_created(resp, 'Untitled1.ipynb', 'foo')
147
150
148 # And two directories down
151 # And two directories down
149 resp = self.nb_api.create_untitled(path='foo/bar')
152 resp = self.nb_api.create_untitled(path='foo/bar')
150 self._check_nb_created(resp, 'Untitled0.ipynb', pjoin('foo', 'bar'))
153 self._check_nb_created(resp, 'Untitled0.ipynb', pjoin('foo', 'bar'))
151
154
152 def test_upload(self):
155 def test_upload(self):
153 nb = new_notebook(name='Upload test')
156 nb = new_notebook(name='Upload test')
154 nbmodel = {'content': nb}
157 nbmodel = {'content': nb}
155 resp = self.nb_api.upload('Upload test.ipynb', path='foo',
158 resp = self.nb_api.upload('Upload test.ipynb', path='foo',
156 body=jsonapi.dumps(nbmodel))
159 body=jsonapi.dumps(nbmodel))
157 self._check_nb_created(resp, 'Upload test.ipynb', 'foo')
160 self._check_nb_created(resp, 'Upload test.ipynb', 'foo')
158
161
159 def test_copy(self):
162 def test_copy(self):
160 resp = self.nb_api.copy('a.ipynb', path='foo')
163 resp = self.nb_api.copy('a.ipynb', path='foo')
161 self._check_nb_created(resp, 'a-Copy0.ipynb', 'foo')
164 self._check_nb_created(resp, 'a-Copy0.ipynb', 'foo')
162
165
163 def test_delete(self):
166 def test_delete(self):
164 for d, name in self.dirs_nbs:
167 for d, name in self.dirs_nbs:
165 resp = self.nb_api.delete('%s.ipynb' % name, d)
168 resp = self.nb_api.delete('%s.ipynb' % name, d)
166 self.assertEqual(resp.status_code, 204)
169 self.assertEqual(resp.status_code, 204)
167
170
168 for d in self.dirs + ['/']:
171 for d in self.dirs + ['/']:
169 nbs = self.nb_api.list(d).json()
172 nbs = self.nb_api.list(d).json()
170 self.assertEqual(len(nbs), 0)
173 self.assertEqual(len(nbs), 0)
171
174
172 def test_rename(self):
175 def test_rename(self):
173 resp = self.nb_api.rename('a.ipynb', 'foo', 'z.ipynb')
176 resp = self.nb_api.rename('a.ipynb', 'foo', 'z.ipynb')
174 if False:
177 if False:
175 # XXX: Spec says this should be set, but it isn't
178 # XXX: Spec says this should be set, but it isn't
176 self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb')
179 self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb')
177 self.assertEqual(resp.json()['name'], 'z.ipynb')
180 self.assertEqual(resp.json()['name'], 'z.ipynb')
178 assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb'))
181 assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb'))
179
182
180 nbs = self.nb_api.list('foo').json()
183 nbs = self.nb_api.list('foo').json()
181 nbnames = set(n['name'] for n in nbs)
184 nbnames = set(n['name'] for n in nbs)
182 self.assertIn('z.ipynb', nbnames)
185 self.assertIn('z.ipynb', nbnames)
183 self.assertNotIn('a.ipynb', nbnames)
186 self.assertNotIn('a.ipynb', nbnames)
184
187
185 def test_save(self):
188 def test_save(self):
186 resp = self.nb_api.read('a.ipynb', 'foo')
189 resp = self.nb_api.read('a.ipynb', 'foo')
187 nbcontent = jsonapi.loads(resp.text)['content']
190 nbcontent = jsonapi.loads(resp.text)['content']
188 nb = to_notebook_json(nbcontent)
191 nb = to_notebook_json(nbcontent)
189 ws = new_worksheet()
192 ws = new_worksheet()
190 nb.worksheets = [ws]
193 nb.worksheets = [ws]
191 ws.cells.append(new_heading_cell('Created by test'))
194 ws.cells.append(new_heading_cell('Created by test'))
192
195
193 nbmodel= {'name': 'a.ipynb', 'path':'foo', 'content': nb}
196 nbmodel= {'name': 'a.ipynb', 'path':'foo', 'content': nb}
194 resp = self.nb_api.save('a.ipynb', path='foo', body=jsonapi.dumps(nbmodel))
197 resp = self.nb_api.save('a.ipynb', path='foo', body=jsonapi.dumps(nbmodel))
195
198
196 nbfile = pjoin(self.notebook_dir.name, 'foo', 'a.ipynb')
199 nbfile = pjoin(self.notebook_dir.name, 'foo', 'a.ipynb')
197 with open(nbfile, 'r') as f:
200 with open(nbfile, 'r') as f:
198 newnb = read(f, format='ipynb')
201 newnb = read(f, format='ipynb')
199 self.assertEqual(newnb.worksheets[0].cells[0].source,
202 self.assertEqual(newnb.worksheets[0].cells[0].source,
200 'Created by test')
203 'Created by test')
204
205 # Save and rename
206 nbmodel= {'name': 'a2.ipynb', 'path':'foo/bar', 'content': nb}
207 resp = self.nb_api.save('a.ipynb', path='foo', body=jsonapi.dumps(nbmodel))
208 saved = resp.json()
209 self.assertEqual(saved['name'], 'a2.ipynb')
210 self.assertEqual(saved['path'], 'foo/bar')
211 assert os.path.isfile(pjoin(self.notebook_dir.name,'foo','bar','a2.ipynb'))
212 assert not os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'a.ipynb'))
213 self.assert_404('a.ipynb', 'foo') No newline at end of file
General Comments 0
You need to be logged in to leave comments. Login now