##// END OF EJS Templates
Fix various review comments
Thomas Kluyver -
Show More
@@ -1,559 +1,559 b''
1 """A contents manager that uses the local file system for storage."""
1 """A contents manager that uses the local file system for storage."""
2
2
3 # Copyright (c) IPython Development Team.
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
4 # Distributed under the terms of the Modified BSD License.
5
5
6 import base64
6 import base64
7 import io
7 import io
8 import os
8 import os
9 import glob
9 import glob
10 import shutil
10 import shutil
11
11
12 from tornado import web
12 from tornado import web
13
13
14 from .manager import ContentsManager
14 from .manager import ContentsManager
15 from IPython import nbformat
15 from IPython import nbformat
16 from IPython.utils.io import atomic_writing
16 from IPython.utils.io import atomic_writing
17 from IPython.utils.path import ensure_dir_exists
17 from IPython.utils.path import ensure_dir_exists
18 from IPython.utils.traitlets import Unicode, Bool, TraitError
18 from IPython.utils.traitlets import Unicode, Bool, TraitError
19 from IPython.utils.py3compat import getcwd
19 from IPython.utils.py3compat import getcwd
20 from IPython.utils import tz
20 from IPython.utils import tz
21 from IPython.html.utils import is_hidden, to_os_path, url_path_join
21 from IPython.html.utils import is_hidden, to_os_path, url_path_join
22
22
23
23
24 class FileContentsManager(ContentsManager):
24 class FileContentsManager(ContentsManager):
25
25
26 root_dir = Unicode(getcwd(), config=True)
26 root_dir = Unicode(getcwd(), config=True)
27
27
28 save_script = Bool(False, config=True, help='DEPRECATED, IGNORED')
28 save_script = Bool(False, config=True, help='DEPRECATED, IGNORED')
29 def _save_script_changed(self):
29 def _save_script_changed(self):
30 self.log.warn("""
30 self.log.warn("""
31 Automatically saving notebooks as scripts has been removed.
31 Automatically saving notebooks as scripts has been removed.
32 Use `ipython nbconvert --to python [notebook]` instead.
32 Use `ipython nbconvert --to python [notebook]` instead.
33 """)
33 """)
34
34
35 def _root_dir_changed(self, name, old, new):
35 def _root_dir_changed(self, name, old, new):
36 """Do a bit of validation of the root_dir."""
36 """Do a bit of validation of the root_dir."""
37 if not os.path.isabs(new):
37 if not os.path.isabs(new):
38 # If we receive a non-absolute path, make it absolute.
38 # If we receive a non-absolute path, make it absolute.
39 self.root_dir = os.path.abspath(new)
39 self.root_dir = os.path.abspath(new)
40 return
40 return
41 if not os.path.isdir(new):
41 if not os.path.isdir(new):
42 raise TraitError("%r is not a directory" % new)
42 raise TraitError("%r is not a directory" % new)
43
43
44 checkpoint_dir = Unicode('.ipynb_checkpoints', config=True,
44 checkpoint_dir = Unicode('.ipynb_checkpoints', config=True,
45 help="""The directory name in which to keep file checkpoints
45 help="""The directory name in which to keep file checkpoints
46
46
47 This is a path relative to the file's own directory.
47 This is a path relative to the file's own directory.
48
48
49 By default, it is .ipynb_checkpoints
49 By default, it is .ipynb_checkpoints
50 """
50 """
51 )
51 )
52
52
53 def _copy(self, src, dest):
53 def _copy(self, src, dest):
54 """copy src to dest
54 """copy src to dest
55
55
56 like shutil.copy2, but log errors in copystat
56 like shutil.copy2, but log errors in copystat
57 """
57 """
58 shutil.copyfile(src, dest)
58 shutil.copyfile(src, dest)
59 try:
59 try:
60 shutil.copystat(src, dest)
60 shutil.copystat(src, dest)
61 except OSError as e:
61 except OSError as e:
62 self.log.debug("copystat on %s failed", dest, exc_info=True)
62 self.log.debug("copystat on %s failed", dest, exc_info=True)
63
63
64 def _get_os_path(self, path):
64 def _get_os_path(self, path):
65 """Given an API path, return its file system path.
65 """Given an API path, return its file system path.
66
66
67 Parameters
67 Parameters
68 ----------
68 ----------
69 path : string
69 path : string
70 The relative API path to the named file.
70 The relative API path to the named file.
71
71
72 Returns
72 Returns
73 -------
73 -------
74 path : string
74 path : string
75 Native, absolute OS path to for a file.
75 Native, absolute OS path to for a file.
76 """
76 """
77 return to_os_path(path, self.root_dir)
77 return to_os_path(path, self.root_dir)
78
78
79 def dir_exists(self, path):
79 def dir_exists(self, path):
80 """Does the API-style path refer to an extant directory?
80 """Does the API-style path refer to an extant directory?
81
81
82 API-style wrapper for os.path.isdir
82 API-style wrapper for os.path.isdir
83
83
84 Parameters
84 Parameters
85 ----------
85 ----------
86 path : string
86 path : string
87 The path to check. This is an API path (`/` separated,
87 The path to check. This is an API path (`/` separated,
88 relative to root_dir).
88 relative to root_dir).
89
89
90 Returns
90 Returns
91 -------
91 -------
92 exists : bool
92 exists : bool
93 Whether the path is indeed a directory.
93 Whether the path is indeed a directory.
94 """
94 """
95 path = path.strip('/')
95 path = path.strip('/')
96 os_path = self._get_os_path(path=path)
96 os_path = self._get_os_path(path=path)
97 return os.path.isdir(os_path)
97 return os.path.isdir(os_path)
98
98
99 def is_hidden(self, path):
99 def is_hidden(self, path):
100 """Does the API style path correspond to a hidden directory or file?
100 """Does the API style path correspond to a hidden directory or file?
101
101
102 Parameters
102 Parameters
103 ----------
103 ----------
104 path : string
104 path : string
105 The path to check. This is an API path (`/` separated,
105 The path to check. This is an API path (`/` separated,
106 relative to root_dir).
106 relative to root_dir).
107
107
108 Returns
108 Returns
109 -------
109 -------
110 hidden : bool
110 hidden : bool
111 Whether the path exists and is hidden.
111 Whether the path exists and is hidden.
112 """
112 """
113 path = path.strip('/')
113 path = path.strip('/')
114 os_path = self._get_os_path(path=path)
114 os_path = self._get_os_path(path=path)
115 return is_hidden(os_path, self.root_dir)
115 return is_hidden(os_path, self.root_dir)
116
116
117 def file_exists(self, path):
117 def file_exists(self, path):
118 """Returns True if the file exists, else returns False.
118 """Returns True if the file exists, else returns False.
119
119
120 API-style wrapper for os.path.isfile
120 API-style wrapper for os.path.isfile
121
121
122 Parameters
122 Parameters
123 ----------
123 ----------
124 path : string
124 path : string
125 The relative path to the file (with '/' as separator)
125 The relative path to the file (with '/' as separator)
126
126
127 Returns
127 Returns
128 -------
128 -------
129 exists : bool
129 exists : bool
130 Whether the file exists.
130 Whether the file exists.
131 """
131 """
132 path = path.strip('/')
132 path = path.strip('/')
133 os_path = self._get_os_path(path)
133 os_path = self._get_os_path(path)
134 return os.path.isfile(os_path)
134 return os.path.isfile(os_path)
135
135
136 def exists(self, path):
136 def exists(self, path):
137 """Returns True if the path exists, else returns False.
137 """Returns True if the path exists, else returns False.
138
138
139 API-style wrapper for os.path.exists
139 API-style wrapper for os.path.exists
140
140
141 Parameters
141 Parameters
142 ----------
142 ----------
143 path : string
143 path : string
144 The API path to the file (with '/' as separator)
144 The API path to the file (with '/' as separator)
145
145
146 Returns
146 Returns
147 -------
147 -------
148 exists : bool
148 exists : bool
149 Whether the target exists.
149 Whether the target exists.
150 """
150 """
151 path = path.strip('/')
151 path = path.strip('/')
152 os_path = self._get_os_path(path=path)
152 os_path = self._get_os_path(path=path)
153 return os.path.exists(os_path)
153 return os.path.exists(os_path)
154
154
155 def _base_model(self, path):
155 def _base_model(self, path):
156 """Build the common base of a contents model"""
156 """Build the common base of a contents model"""
157 os_path = self._get_os_path(path)
157 os_path = self._get_os_path(path)
158 info = os.stat(os_path)
158 info = os.stat(os_path)
159 last_modified = tz.utcfromtimestamp(info.st_mtime)
159 last_modified = tz.utcfromtimestamp(info.st_mtime)
160 created = tz.utcfromtimestamp(info.st_ctime)
160 created = tz.utcfromtimestamp(info.st_ctime)
161 # Create the base model.
161 # Create the base model.
162 model = {}
162 model = {}
163 model['name'] = path.rsplit('/', 1)[-1]
163 model['name'] = path.rsplit('/', 1)[-1]
164 model['path'] = path
164 model['path'] = path
165 model['last_modified'] = last_modified
165 model['last_modified'] = last_modified
166 model['created'] = created
166 model['created'] = created
167 model['content'] = None
167 model['content'] = None
168 model['format'] = None
168 model['format'] = None
169 return model
169 return model
170
170
171 def _dir_model(self, path, content=True):
171 def _dir_model(self, path, content=True):
172 """Build a model for a directory
172 """Build a model for a directory
173
173
174 if content is requested, will include a listing of the directory
174 if content is requested, will include a listing of the directory
175 """
175 """
176 os_path = self._get_os_path(path)
176 os_path = self._get_os_path(path)
177
177
178 four_o_four = u'directory does not exist: %r' % os_path
178 four_o_four = u'directory does not exist: %r' % os_path
179
179
180 if not os.path.isdir(os_path):
180 if not os.path.isdir(os_path):
181 raise web.HTTPError(404, four_o_four)
181 raise web.HTTPError(404, four_o_four)
182 elif is_hidden(os_path, self.root_dir):
182 elif is_hidden(os_path, self.root_dir):
183 self.log.info("Refusing to serve hidden directory %r, via 404 Error",
183 self.log.info("Refusing to serve hidden directory %r, via 404 Error",
184 os_path
184 os_path
185 )
185 )
186 raise web.HTTPError(404, four_o_four)
186 raise web.HTTPError(404, four_o_four)
187
187
188 model = self._base_model(path)
188 model = self._base_model(path)
189 model['type'] = 'directory'
189 model['type'] = 'directory'
190 if content:
190 if content:
191 model['content'] = contents = []
191 model['content'] = contents = []
192 os_dir = self._get_os_path(path)
192 os_dir = self._get_os_path(path)
193 for name in os.listdir(os_dir):
193 for name in os.listdir(os_dir):
194 os_path = os.path.join(os_dir, name)
194 os_path = os.path.join(os_dir, name)
195 # skip over broken symlinks in listing
195 # skip over broken symlinks in listing
196 if not os.path.exists(os_path):
196 if not os.path.exists(os_path):
197 self.log.warn("%s doesn't exist", os_path)
197 self.log.warn("%s doesn't exist", os_path)
198 continue
198 continue
199 elif not os.path.isfile(os_path) and not os.path.isdir(os_path):
199 elif not os.path.isfile(os_path) and not os.path.isdir(os_path):
200 self.log.debug("%s not a regular file", os_path)
200 self.log.debug("%s not a regular file", os_path)
201 continue
201 continue
202 if self.should_list(name) and not is_hidden(os_path, self.root_dir):
202 if self.should_list(name) and not is_hidden(os_path, self.root_dir):
203 contents.append(self.get_model(
203 contents.append(self.get_model(
204 path='%s/%s' % (path, name),
204 path='%s/%s' % (path, name),
205 content=False)
205 content=False)
206 )
206 )
207
207
208 model['format'] = 'json'
208 model['format'] = 'json'
209
209
210 return model
210 return model
211
211
212 def _file_model(self, path, content=True, format=None):
212 def _file_model(self, path, content=True, format=None):
213 """Build a model for a file
213 """Build a model for a file
214
214
215 if content is requested, include the file contents.
215 if content is requested, include the file contents.
216
216
217 format:
217 format:
218 If 'text', the contents will be decoded as UTF-8.
218 If 'text', the contents will be decoded as UTF-8.
219 If 'base64', the raw bytes contents will be encoded as base64.
219 If 'base64', the raw bytes contents will be encoded as base64.
220 If not specified, try to decode as UTF-8, and fall back to base64
220 If not specified, try to decode as UTF-8, and fall back to base64
221 """
221 """
222 model = self._base_model(path)
222 model = self._base_model(path)
223 model['type'] = 'file'
223 model['type'] = 'file'
224 if content:
224 if content:
225 os_path = self._get_os_path(path)
225 os_path = self._get_os_path(path)
226 if not os.path.isfile(os_path):
226 if not os.path.isfile(os_path):
227 # could be FIFO
227 # could be FIFO
228 raise web.HTTPError(400, "Cannot get content of non-file %s" % os_path)
228 raise web.HTTPError(400, "Cannot get content of non-file %s" % os_path)
229 with io.open(os_path, 'rb') as f:
229 with io.open(os_path, 'rb') as f:
230 bcontent = f.read()
230 bcontent = f.read()
231
231
232 if format != 'base64':
232 if format != 'base64':
233 try:
233 try:
234 model['content'] = bcontent.decode('utf8')
234 model['content'] = bcontent.decode('utf8')
235 except UnicodeError as e:
235 except UnicodeError as e:
236 if format == 'text':
236 if format == 'text':
237 raise web.HTTPError(400, "%s is not UTF-8 encoded" % path)
237 raise web.HTTPError(400, "%s is not UTF-8 encoded" % path)
238 else:
238 else:
239 model['format'] = 'text'
239 model['format'] = 'text'
240
240
241 if model['content'] is None:
241 if model['content'] is None:
242 model['content'] = base64.encodestring(bcontent).decode('ascii')
242 model['content'] = base64.encodestring(bcontent).decode('ascii')
243 model['format'] = 'base64'
243 model['format'] = 'base64'
244
244
245 return model
245 return model
246
246
247
247
248 def _notebook_model(self, path, content=True):
248 def _notebook_model(self, path, content=True):
249 """Build a notebook model
249 """Build a notebook model
250
250
251 if content is requested, the notebook content will be populated
251 if content is requested, the notebook content will be populated
252 as a JSON structure (not double-serialized)
252 as a JSON structure (not double-serialized)
253 """
253 """
254 model = self._base_model(path)
254 model = self._base_model(path)
255 model['type'] = 'notebook'
255 model['type'] = 'notebook'
256 if content:
256 if content:
257 os_path = self._get_os_path(path)
257 os_path = self._get_os_path(path)
258 with io.open(os_path, 'r', encoding='utf-8') as f:
258 with io.open(os_path, 'r', encoding='utf-8') as f:
259 try:
259 try:
260 nb = nbformat.read(f, as_version=4)
260 nb = nbformat.read(f, as_version=4)
261 except Exception as e:
261 except Exception as e:
262 raise web.HTTPError(400, u"Unreadable Notebook: %s %r" % (os_path, e))
262 raise web.HTTPError(400, u"Unreadable Notebook: %s %r" % (os_path, e))
263 self.mark_trusted_cells(nb, path)
263 self.mark_trusted_cells(nb, path)
264 model['content'] = nb
264 model['content'] = nb
265 model['format'] = 'json'
265 model['format'] = 'json'
266 self.validate_notebook_model(model)
266 self.validate_notebook_model(model)
267 return model
267 return model
268
268
269 def get_model(self, path, content=True, type_=None, format=None):
269 def get_model(self, path, content=True, type_=None, format=None):
270 """ Takes a path for an entity and returns its model
270 """ Takes a path for an entity and returns its model
271
271
272 Parameters
272 Parameters
273 ----------
273 ----------
274 path : str
274 path : str
275 the API path that describes the relative path for the target
275 the API path that describes the relative path for the target
276 content : bool
276 content : bool
277 Whether to include the contents in the reply
277 Whether to include the contents in the reply
278 type_ : str, optional
278 type_ : str, optional
279 The requested type - 'file', 'notebook', or 'directory'.
279 The requested type - 'file', 'notebook', or 'directory'.
280 Will raise HTTPError 406 if the content doesn't match.
280 Will raise HTTPError 400 if the content doesn't match.
281 format : str, optional
281 format : str, optional
282 The requested format for file contents. 'text' or 'base64'.
282 The requested format for file contents. 'text' or 'base64'.
283 Ignored if this returns a notebook or directory model.
283 Ignored if this returns a notebook or directory model.
284
284
285 Returns
285 Returns
286 -------
286 -------
287 model : dict
287 model : dict
288 the contents model. If content=True, returns the contents
288 the contents model. If content=True, returns the contents
289 of the file or directory as well.
289 of the file or directory as well.
290 """
290 """
291 path = path.strip('/')
291 path = path.strip('/')
292
292
293 if not self.exists(path):
293 if not self.exists(path):
294 raise web.HTTPError(404, u'No such file or directory: %s' % path)
294 raise web.HTTPError(404, u'No such file or directory: %s' % path)
295
295
296 os_path = self._get_os_path(path)
296 os_path = self._get_os_path(path)
297 if os.path.isdir(os_path):
297 if os.path.isdir(os_path):
298 if type_ not in (None, 'directory'):
298 if type_ not in (None, 'directory'):
299 raise web.HTTPError(400,
299 raise web.HTTPError(400,
300 u'%s is a directory, not a %s' % (path, type_))
300 u'%s is a directory, not a %s' % (path, type_))
301 model = self._dir_model(path, content=content)
301 model = self._dir_model(path, content=content)
302 elif type_ == 'notebook' or (type_ is None and path.endswith('.ipynb')):
302 elif type_ == 'notebook' or (type_ is None and path.endswith('.ipynb')):
303 model = self._notebook_model(path, content=content)
303 model = self._notebook_model(path, content=content)
304 else:
304 else:
305 if type_ == 'directory':
305 if type_ == 'directory':
306 raise web.HTTPError(400,
306 raise web.HTTPError(400,
307 u'%s is not a directory')
307 u'%s is not a directory')
308 model = self._file_model(path, content=content, format=format)
308 model = self._file_model(path, content=content, format=format)
309 return model
309 return model
310
310
311 def _save_notebook(self, os_path, model, path=''):
311 def _save_notebook(self, os_path, model, path=''):
312 """save a notebook file"""
312 """save a notebook file"""
313 # Save the notebook file
313 # Save the notebook file
314 nb = nbformat.from_dict(model['content'])
314 nb = nbformat.from_dict(model['content'])
315
315
316 self.check_and_sign(nb, path)
316 self.check_and_sign(nb, path)
317
317
318 with atomic_writing(os_path, encoding='utf-8') as f:
318 with atomic_writing(os_path, encoding='utf-8') as f:
319 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
319 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
320
320
321 def _save_file(self, os_path, model, path=''):
321 def _save_file(self, os_path, model, path=''):
322 """save a non-notebook file"""
322 """save a non-notebook file"""
323 fmt = model.get('format', None)
323 fmt = model.get('format', None)
324 if fmt not in {'text', 'base64'}:
324 if fmt not in {'text', 'base64'}:
325 raise web.HTTPError(400, "Must specify format of file contents as 'text' or 'base64'")
325 raise web.HTTPError(400, "Must specify format of file contents as 'text' or 'base64'")
326 try:
326 try:
327 content = model['content']
327 content = model['content']
328 if fmt == 'text':
328 if fmt == 'text':
329 bcontent = content.encode('utf8')
329 bcontent = content.encode('utf8')
330 else:
330 else:
331 b64_bytes = content.encode('ascii')
331 b64_bytes = content.encode('ascii')
332 bcontent = base64.decodestring(b64_bytes)
332 bcontent = base64.decodestring(b64_bytes)
333 except Exception as e:
333 except Exception as e:
334 raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e))
334 raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e))
335 with atomic_writing(os_path, text=False) as f:
335 with atomic_writing(os_path, text=False) as f:
336 f.write(bcontent)
336 f.write(bcontent)
337
337
338 def _save_directory(self, os_path, model, path=''):
338 def _save_directory(self, os_path, model, path=''):
339 """create a directory"""
339 """create a directory"""
340 if is_hidden(os_path, self.root_dir):
340 if is_hidden(os_path, self.root_dir):
341 raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path)
341 raise web.HTTPError(400, u'Cannot create hidden directory %r' % os_path)
342 if not os.path.exists(os_path):
342 if not os.path.exists(os_path):
343 os.mkdir(os_path)
343 os.mkdir(os_path)
344 elif not os.path.isdir(os_path):
344 elif not os.path.isdir(os_path):
345 raise web.HTTPError(400, u'Not a directory: %s' % (os_path))
345 raise web.HTTPError(400, u'Not a directory: %s' % (os_path))
346 else:
346 else:
347 self.log.debug("Directory %r already exists", os_path)
347 self.log.debug("Directory %r already exists", os_path)
348
348
349 def save(self, model, path=''):
349 def save(self, model, path=''):
350 """Save the file model and return the model with no content."""
350 """Save the file model and return the model with no content."""
351 path = path.strip('/')
351 path = path.strip('/')
352
352
353 if 'type' not in model:
353 if 'type' not in model:
354 raise web.HTTPError(400, u'No file type provided')
354 raise web.HTTPError(400, u'No file type provided')
355 if 'content' not in model and model['type'] != 'directory':
355 if 'content' not in model and model['type'] != 'directory':
356 raise web.HTTPError(400, u'No file content provided')
356 raise web.HTTPError(400, u'No file content provided')
357
357
358 # One checkpoint should always exist
358 # One checkpoint should always exist
359 if self.file_exists(path) and not self.list_checkpoints(path):
359 if self.file_exists(path) and not self.list_checkpoints(path):
360 self.create_checkpoint(path)
360 self.create_checkpoint(path)
361
361
362 os_path = self._get_os_path(path)
362 os_path = self._get_os_path(path)
363 self.log.debug("Saving %s", os_path)
363 self.log.debug("Saving %s", os_path)
364 try:
364 try:
365 if model['type'] == 'notebook':
365 if model['type'] == 'notebook':
366 self._save_notebook(os_path, model, path)
366 self._save_notebook(os_path, model, path)
367 elif model['type'] == 'file':
367 elif model['type'] == 'file':
368 self._save_file(os_path, model, path)
368 self._save_file(os_path, model, path)
369 elif model['type'] == 'directory':
369 elif model['type'] == 'directory':
370 self._save_directory(os_path, model, path)
370 self._save_directory(os_path, model, path)
371 else:
371 else:
372 raise web.HTTPError(400, "Unhandled contents type: %s" % model['type'])
372 raise web.HTTPError(400, "Unhandled contents type: %s" % model['type'])
373 except web.HTTPError:
373 except web.HTTPError:
374 raise
374 raise
375 except Exception as e:
375 except Exception as e:
376 raise web.HTTPError(400, u'Unexpected error while saving file: %s %s' % (os_path, e))
376 raise web.HTTPError(400, u'Unexpected error while saving file: %s %s' % (os_path, e))
377
377
378 validation_message = None
378 validation_message = None
379 if model['type'] == 'notebook':
379 if model['type'] == 'notebook':
380 self.validate_notebook_model(model)
380 self.validate_notebook_model(model)
381 validation_message = model.get('message', None)
381 validation_message = model.get('message', None)
382
382
383 model = self.get_model(path, content=False)
383 model = self.get_model(path, content=False)
384 if validation_message:
384 if validation_message:
385 model['message'] = validation_message
385 model['message'] = validation_message
386 return model
386 return model
387
387
388 def update(self, model, path):
388 def update(self, model, path):
389 """Update the file's path
389 """Update the file's path
390
390
391 For use in PATCH requests, to enable renaming a file without
391 For use in PATCH requests, to enable renaming a file without
392 re-uploading its contents. Only used for renaming at the moment.
392 re-uploading its contents. Only used for renaming at the moment.
393 """
393 """
394 path = path.strip('/')
394 path = path.strip('/')
395 new_path = model.get('path', path).strip('/')
395 new_path = model.get('path', path).strip('/')
396 if path != new_path:
396 if path != new_path:
397 self.rename(path, new_path)
397 self.rename(path, new_path)
398 model = self.get_model(new_path, content=False)
398 model = self.get_model(new_path, content=False)
399 return model
399 return model
400
400
401 def delete(self, path):
401 def delete(self, path):
402 """Delete file at path."""
402 """Delete file at path."""
403 path = path.strip('/')
403 path = path.strip('/')
404 os_path = self._get_os_path(path)
404 os_path = self._get_os_path(path)
405 rm = os.unlink
405 rm = os.unlink
406 if os.path.isdir(os_path):
406 if os.path.isdir(os_path):
407 listing = os.listdir(os_path)
407 listing = os.listdir(os_path)
408 # don't delete non-empty directories (checkpoints dir doesn't count)
408 # don't delete non-empty directories (checkpoints dir doesn't count)
409 if listing and listing != [self.checkpoint_dir]:
409 if listing and listing != [self.checkpoint_dir]:
410 raise web.HTTPError(400, u'Directory %s not empty' % os_path)
410 raise web.HTTPError(400, u'Directory %s not empty' % os_path)
411 elif not os.path.isfile(os_path):
411 elif not os.path.isfile(os_path):
412 raise web.HTTPError(404, u'File does not exist: %s' % os_path)
412 raise web.HTTPError(404, u'File does not exist: %s' % os_path)
413
413
414 # clear checkpoints
414 # clear checkpoints
415 for checkpoint in self.list_checkpoints(path):
415 for checkpoint in self.list_checkpoints(path):
416 checkpoint_id = checkpoint['id']
416 checkpoint_id = checkpoint['id']
417 cp_path = self.get_checkpoint_path(checkpoint_id, path)
417 cp_path = self.get_checkpoint_path(checkpoint_id, path)
418 if os.path.isfile(cp_path):
418 if os.path.isfile(cp_path):
419 self.log.debug("Unlinking checkpoint %s", cp_path)
419 self.log.debug("Unlinking checkpoint %s", cp_path)
420 os.unlink(cp_path)
420 os.unlink(cp_path)
421
421
422 if os.path.isdir(os_path):
422 if os.path.isdir(os_path):
423 self.log.debug("Removing directory %s", os_path)
423 self.log.debug("Removing directory %s", os_path)
424 shutil.rmtree(os_path)
424 shutil.rmtree(os_path)
425 else:
425 else:
426 self.log.debug("Unlinking file %s", os_path)
426 self.log.debug("Unlinking file %s", os_path)
427 rm(os_path)
427 rm(os_path)
428
428
429 def rename(self, old_path, new_path):
429 def rename(self, old_path, new_path):
430 """Rename a file."""
430 """Rename a file."""
431 old_path = old_path.strip('/')
431 old_path = old_path.strip('/')
432 new_path = new_path.strip('/')
432 new_path = new_path.strip('/')
433 if new_path == old_path:
433 if new_path == old_path:
434 return
434 return
435
435
436 new_os_path = self._get_os_path(new_path)
436 new_os_path = self._get_os_path(new_path)
437 old_os_path = self._get_os_path(old_path)
437 old_os_path = self._get_os_path(old_path)
438
438
439 # Should we proceed with the move?
439 # Should we proceed with the move?
440 if os.path.exists(new_os_path):
440 if os.path.exists(new_os_path):
441 raise web.HTTPError(409, u'File already exists: %s' % new_path)
441 raise web.HTTPError(409, u'File already exists: %s' % new_path)
442
442
443 # Move the file
443 # Move the file
444 try:
444 try:
445 shutil.move(old_os_path, new_os_path)
445 shutil.move(old_os_path, new_os_path)
446 except Exception as e:
446 except Exception as e:
447 raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e))
447 raise web.HTTPError(500, u'Unknown error renaming file: %s %s' % (old_path, e))
448
448
449 # Move the checkpoints
449 # Move the checkpoints
450 old_checkpoints = self.list_checkpoints(old_path)
450 old_checkpoints = self.list_checkpoints(old_path)
451 for cp in old_checkpoints:
451 for cp in old_checkpoints:
452 checkpoint_id = cp['id']
452 checkpoint_id = cp['id']
453 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path)
453 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path)
454 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path)
454 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path)
455 if os.path.isfile(old_cp_path):
455 if os.path.isfile(old_cp_path):
456 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
456 self.log.debug("Renaming checkpoint %s -> %s", old_cp_path, new_cp_path)
457 shutil.move(old_cp_path, new_cp_path)
457 shutil.move(old_cp_path, new_cp_path)
458
458
459 # Checkpoint-related utilities
459 # Checkpoint-related utilities
460
460
461 def get_checkpoint_path(self, checkpoint_id, path):
461 def get_checkpoint_path(self, checkpoint_id, path):
462 """find the path to a checkpoint"""
462 """find the path to a checkpoint"""
463 path = path.strip('/')
463 path = path.strip('/')
464 parent, name = ('/' + path).rsplit('/', 1)
464 parent, name = ('/' + path).rsplit('/', 1)
465 parent = parent.strip('/')
465 parent = parent.strip('/')
466 basename, ext = os.path.splitext(name)
466 basename, ext = os.path.splitext(name)
467 filename = u"{name}-{checkpoint_id}{ext}".format(
467 filename = u"{name}-{checkpoint_id}{ext}".format(
468 name=basename,
468 name=basename,
469 checkpoint_id=checkpoint_id,
469 checkpoint_id=checkpoint_id,
470 ext=ext,
470 ext=ext,
471 )
471 )
472 os_path = self._get_os_path(path=parent)
472 os_path = self._get_os_path(path=parent)
473 cp_dir = os.path.join(os_path, self.checkpoint_dir)
473 cp_dir = os.path.join(os_path, self.checkpoint_dir)
474 ensure_dir_exists(cp_dir)
474 ensure_dir_exists(cp_dir)
475 cp_path = os.path.join(cp_dir, filename)
475 cp_path = os.path.join(cp_dir, filename)
476 return cp_path
476 return cp_path
477
477
478 def get_checkpoint_model(self, checkpoint_id, path):
478 def get_checkpoint_model(self, checkpoint_id, path):
479 """construct the info dict for a given checkpoint"""
479 """construct the info dict for a given checkpoint"""
480 path = path.strip('/')
480 path = path.strip('/')
481 cp_path = self.get_checkpoint_path(checkpoint_id, path)
481 cp_path = self.get_checkpoint_path(checkpoint_id, path)
482 stats = os.stat(cp_path)
482 stats = os.stat(cp_path)
483 last_modified = tz.utcfromtimestamp(stats.st_mtime)
483 last_modified = tz.utcfromtimestamp(stats.st_mtime)
484 info = dict(
484 info = dict(
485 id = checkpoint_id,
485 id = checkpoint_id,
486 last_modified = last_modified,
486 last_modified = last_modified,
487 )
487 )
488 return info
488 return info
489
489
490 # public checkpoint API
490 # public checkpoint API
491
491
492 def create_checkpoint(self, path):
492 def create_checkpoint(self, path):
493 """Create a checkpoint from the current state of a file"""
493 """Create a checkpoint from the current state of a file"""
494 path = path.strip('/')
494 path = path.strip('/')
495 if not self.file_exists(path):
495 if not self.file_exists(path):
496 raise web.HTTPError(404)
496 raise web.HTTPError(404)
497 src_path = self._get_os_path(path)
497 src_path = self._get_os_path(path)
498 # only the one checkpoint ID:
498 # only the one checkpoint ID:
499 checkpoint_id = u"checkpoint"
499 checkpoint_id = u"checkpoint"
500 cp_path = self.get_checkpoint_path(checkpoint_id, path)
500 cp_path = self.get_checkpoint_path(checkpoint_id, path)
501 self.log.debug("creating checkpoint for %s", path)
501 self.log.debug("creating checkpoint for %s", path)
502 self._copy(src_path, cp_path)
502 self._copy(src_path, cp_path)
503
503
504 # return the checkpoint info
504 # return the checkpoint info
505 return self.get_checkpoint_model(checkpoint_id, path)
505 return self.get_checkpoint_model(checkpoint_id, path)
506
506
507 def list_checkpoints(self, path):
507 def list_checkpoints(self, path):
508 """list the checkpoints for a given file
508 """list the checkpoints for a given file
509
509
510 This contents manager currently only supports one checkpoint per file.
510 This contents manager currently only supports one checkpoint per file.
511 """
511 """
512 path = path.strip('/')
512 path = path.strip('/')
513 checkpoint_id = "checkpoint"
513 checkpoint_id = "checkpoint"
514 os_path = self.get_checkpoint_path(checkpoint_id, path)
514 os_path = self.get_checkpoint_path(checkpoint_id, path)
515 if not os.path.exists(os_path):
515 if not os.path.exists(os_path):
516 return []
516 return []
517 else:
517 else:
518 return [self.get_checkpoint_model(checkpoint_id, path)]
518 return [self.get_checkpoint_model(checkpoint_id, path)]
519
519
520
520
521 def restore_checkpoint(self, checkpoint_id, path):
521 def restore_checkpoint(self, checkpoint_id, path):
522 """restore a file to a checkpointed state"""
522 """restore a file to a checkpointed state"""
523 path = path.strip('/')
523 path = path.strip('/')
524 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
524 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
525 nb_path = self._get_os_path(path)
525 nb_path = self._get_os_path(path)
526 cp_path = self.get_checkpoint_path(checkpoint_id, path)
526 cp_path = self.get_checkpoint_path(checkpoint_id, path)
527 if not os.path.isfile(cp_path):
527 if not os.path.isfile(cp_path):
528 self.log.debug("checkpoint file does not exist: %s", cp_path)
528 self.log.debug("checkpoint file does not exist: %s", cp_path)
529 raise web.HTTPError(404,
529 raise web.HTTPError(404,
530 u'checkpoint does not exist: %s@%s' % (path, checkpoint_id)
530 u'checkpoint does not exist: %s@%s' % (path, checkpoint_id)
531 )
531 )
532 # ensure notebook is readable (never restore from an unreadable notebook)
532 # ensure notebook is readable (never restore from an unreadable notebook)
533 if cp_path.endswith('.ipynb'):
533 if cp_path.endswith('.ipynb'):
534 with io.open(cp_path, 'r', encoding='utf-8') as f:
534 with io.open(cp_path, 'r', encoding='utf-8') as f:
535 nbformat.read(f, as_version=4)
535 nbformat.read(f, as_version=4)
536 self._copy(cp_path, nb_path)
536 self._copy(cp_path, nb_path)
537 self.log.debug("copying %s -> %s", cp_path, nb_path)
537 self.log.debug("copying %s -> %s", cp_path, nb_path)
538
538
539 def delete_checkpoint(self, checkpoint_id, path):
539 def delete_checkpoint(self, checkpoint_id, path):
540 """delete a file's checkpoint"""
540 """delete a file's checkpoint"""
541 path = path.strip('/')
541 path = path.strip('/')
542 cp_path = self.get_checkpoint_path(checkpoint_id, path)
542 cp_path = self.get_checkpoint_path(checkpoint_id, path)
543 if not os.path.isfile(cp_path):
543 if not os.path.isfile(cp_path):
544 raise web.HTTPError(404,
544 raise web.HTTPError(404,
545 u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id)
545 u'Checkpoint does not exist: %s@%s' % (path, checkpoint_id)
546 )
546 )
547 self.log.debug("unlinking %s", cp_path)
547 self.log.debug("unlinking %s", cp_path)
548 os.unlink(cp_path)
548 os.unlink(cp_path)
549
549
550 def info_string(self):
550 def info_string(self):
551 return "Serving notebooks from local directory: %s" % self.root_dir
551 return "Serving notebooks from local directory: %s" % self.root_dir
552
552
553 def get_kernel_path(self, path, model=None):
553 def get_kernel_path(self, path, model=None):
554 """Return the initial working dir a kernel associated with a given notebook"""
554 """Return the initial working dir a kernel associated with a given notebook"""
555 if '/' in path:
555 if '/' in path:
556 parent_dir = path.rsplit('/', 1)[0]
556 parent_dir = path.rsplit('/', 1)[0]
557 else:
557 else:
558 parent_dir = ''
558 parent_dir = ''
559 return self._get_os_path(parent_dir)
559 return self._get_os_path(parent_dir)
@@ -1,513 +1,511 b''
1 # coding: utf-8
1 # coding: utf-8
2 """Test the contents webservice API."""
2 """Test the contents webservice API."""
3
3
4 import base64
4 import base64
5 import io
5 import io
6 import json
6 import json
7 import os
7 import os
8 import shutil
8 import shutil
9 from unicodedata import normalize
9 from unicodedata import normalize
10
10
11 pjoin = os.path.join
11 pjoin = os.path.join
12
12
13 import requests
13 import requests
14
14
15 from IPython.html.utils import url_path_join, url_escape
15 from IPython.html.utils import url_path_join, url_escape
16 from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error
16 from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error
17 from IPython.nbformat import read, write, from_dict
17 from IPython.nbformat import read, write, from_dict
18 from IPython.nbformat.v4 import (
18 from IPython.nbformat.v4 import (
19 new_notebook, new_markdown_cell,
19 new_notebook, new_markdown_cell,
20 )
20 )
21 from IPython.nbformat import v2
21 from IPython.nbformat import v2
22 from IPython.utils import py3compat
22 from IPython.utils import py3compat
23 from IPython.utils.data import uniq_stable
23 from IPython.utils.data import uniq_stable
24
24
25
25
26 def notebooks_only(dir_model):
26 def notebooks_only(dir_model):
27 return [nb for nb in dir_model['content'] if nb['type']=='notebook']
27 return [nb for nb in dir_model['content'] if nb['type']=='notebook']
28
28
29 def dirs_only(dir_model):
29 def dirs_only(dir_model):
30 return [x for x in dir_model['content'] if x['type']=='directory']
30 return [x for x in dir_model['content'] if x['type']=='directory']
31
31
32
32
33 class API(object):
33 class API(object):
34 """Wrapper for contents API calls."""
34 """Wrapper for contents API calls."""
35 def __init__(self, base_url):
35 def __init__(self, base_url):
36 self.base_url = base_url
36 self.base_url = base_url
37
37
38 def _req(self, verb, path, body=None):
38 def _req(self, verb, path, body=None, params=None):
39 response = requests.request(verb,
39 response = requests.request(verb,
40 url_path_join(self.base_url, 'api/contents', path),
40 url_path_join(self.base_url, 'api/contents', path),
41 data=body,
41 data=body,
42 )
42 )
43 response.raise_for_status()
43 response.raise_for_status()
44 return response
44 return response
45
45
46 def list(self, path='/'):
46 def list(self, path='/'):
47 return self._req('GET', path)
47 return self._req('GET', path)
48
48
49 def read(self, path, type_=None, format=None):
49 def read(self, path, type_=None, format=None):
50 query = []
50 params = {}
51 if type_ is not None:
51 if type_ is not None:
52 query.append('type=' + type_)
52 params['type'] = type_
53 if format is not None:
53 if format is not None:
54 query.append('format=' + format)
54 params['format'] = format
55 if query:
55 return self._req('GET', path, params=params)
56 path += '?' + '&'.join(query)
57 return self._req('GET', path)
58
56
59 def create_untitled(self, path='/', ext='.ipynb'):
57 def create_untitled(self, path='/', ext='.ipynb'):
60 body = None
58 body = None
61 if ext:
59 if ext:
62 body = json.dumps({'ext': ext})
60 body = json.dumps({'ext': ext})
63 return self._req('POST', path, body)
61 return self._req('POST', path, body)
64
62
65 def mkdir_untitled(self, path='/'):
63 def mkdir_untitled(self, path='/'):
66 return self._req('POST', path, json.dumps({'type': 'directory'}))
64 return self._req('POST', path, json.dumps({'type': 'directory'}))
67
65
68 def copy(self, copy_from, path='/'):
66 def copy(self, copy_from, path='/'):
69 body = json.dumps({'copy_from':copy_from})
67 body = json.dumps({'copy_from':copy_from})
70 return self._req('POST', path, body)
68 return self._req('POST', path, body)
71
69
72 def create(self, path='/'):
70 def create(self, path='/'):
73 return self._req('PUT', path)
71 return self._req('PUT', path)
74
72
75 def upload(self, path, body):
73 def upload(self, path, body):
76 return self._req('PUT', path, body)
74 return self._req('PUT', path, body)
77
75
78 def mkdir_untitled(self, path='/'):
76 def mkdir_untitled(self, path='/'):
79 return self._req('POST', path, json.dumps({'type': 'directory'}))
77 return self._req('POST', path, json.dumps({'type': 'directory'}))
80
78
81 def mkdir(self, path='/'):
79 def mkdir(self, path='/'):
82 return self._req('PUT', path, json.dumps({'type': 'directory'}))
80 return self._req('PUT', path, json.dumps({'type': 'directory'}))
83
81
84 def copy_put(self, copy_from, path='/'):
82 def copy_put(self, copy_from, path='/'):
85 body = json.dumps({'copy_from':copy_from})
83 body = json.dumps({'copy_from':copy_from})
86 return self._req('PUT', path, body)
84 return self._req('PUT', path, body)
87
85
88 def save(self, path, body):
86 def save(self, path, body):
89 return self._req('PUT', path, body)
87 return self._req('PUT', path, body)
90
88
91 def delete(self, path='/'):
89 def delete(self, path='/'):
92 return self._req('DELETE', path)
90 return self._req('DELETE', path)
93
91
94 def rename(self, path, new_path):
92 def rename(self, path, new_path):
95 body = json.dumps({'path': new_path})
93 body = json.dumps({'path': new_path})
96 return self._req('PATCH', path, body)
94 return self._req('PATCH', path, body)
97
95
98 def get_checkpoints(self, path):
96 def get_checkpoints(self, path):
99 return self._req('GET', url_path_join(path, 'checkpoints'))
97 return self._req('GET', url_path_join(path, 'checkpoints'))
100
98
101 def new_checkpoint(self, path):
99 def new_checkpoint(self, path):
102 return self._req('POST', url_path_join(path, 'checkpoints'))
100 return self._req('POST', url_path_join(path, 'checkpoints'))
103
101
104 def restore_checkpoint(self, path, checkpoint_id):
102 def restore_checkpoint(self, path, checkpoint_id):
105 return self._req('POST', url_path_join(path, 'checkpoints', checkpoint_id))
103 return self._req('POST', url_path_join(path, 'checkpoints', checkpoint_id))
106
104
107 def delete_checkpoint(self, path, checkpoint_id):
105 def delete_checkpoint(self, path, checkpoint_id):
108 return self._req('DELETE', url_path_join(path, 'checkpoints', checkpoint_id))
106 return self._req('DELETE', url_path_join(path, 'checkpoints', checkpoint_id))
109
107
110 class APITest(NotebookTestBase):
108 class APITest(NotebookTestBase):
111 """Test the kernels web service API"""
109 """Test the kernels web service API"""
112 dirs_nbs = [('', 'inroot'),
110 dirs_nbs = [('', 'inroot'),
113 ('Directory with spaces in', 'inspace'),
111 ('Directory with spaces in', 'inspace'),
114 (u'unicodΓ©', 'innonascii'),
112 (u'unicodΓ©', 'innonascii'),
115 ('foo', 'a'),
113 ('foo', 'a'),
116 ('foo', 'b'),
114 ('foo', 'b'),
117 ('foo', 'name with spaces'),
115 ('foo', 'name with spaces'),
118 ('foo', u'unicodΓ©'),
116 ('foo', u'unicodΓ©'),
119 ('foo/bar', 'baz'),
117 ('foo/bar', 'baz'),
120 ('ordering', 'A'),
118 ('ordering', 'A'),
121 ('ordering', 'b'),
119 ('ordering', 'b'),
122 ('ordering', 'C'),
120 ('ordering', 'C'),
123 (u'Γ₯ b', u'Γ§ d'),
121 (u'Γ₯ b', u'Γ§ d'),
124 ]
122 ]
125 hidden_dirs = ['.hidden', '__pycache__']
123 hidden_dirs = ['.hidden', '__pycache__']
126
124
127 dirs = uniq_stable([py3compat.cast_unicode(d) for (d,n) in dirs_nbs])
125 dirs = uniq_stable([py3compat.cast_unicode(d) for (d,n) in dirs_nbs])
128 del dirs[0] # remove ''
126 del dirs[0] # remove ''
129 top_level_dirs = {normalize('NFC', d.split('/')[0]) for d in dirs}
127 top_level_dirs = {normalize('NFC', d.split('/')[0]) for d in dirs}
130
128
131 @staticmethod
129 @staticmethod
132 def _blob_for_name(name):
130 def _blob_for_name(name):
133 return name.encode('utf-8') + b'\xFF'
131 return name.encode('utf-8') + b'\xFF'
134
132
135 @staticmethod
133 @staticmethod
136 def _txt_for_name(name):
134 def _txt_for_name(name):
137 return u'%s text file' % name
135 return u'%s text file' % name
138
136
139 def setUp(self):
137 def setUp(self):
140 nbdir = self.notebook_dir.name
138 nbdir = self.notebook_dir.name
141 self.blob = os.urandom(100)
139 self.blob = os.urandom(100)
142 self.b64_blob = base64.encodestring(self.blob).decode('ascii')
140 self.b64_blob = base64.encodestring(self.blob).decode('ascii')
143
141
144 for d in (self.dirs + self.hidden_dirs):
142 for d in (self.dirs + self.hidden_dirs):
145 d.replace('/', os.sep)
143 d.replace('/', os.sep)
146 if not os.path.isdir(pjoin(nbdir, d)):
144 if not os.path.isdir(pjoin(nbdir, d)):
147 os.mkdir(pjoin(nbdir, d))
145 os.mkdir(pjoin(nbdir, d))
148
146
149 for d, name in self.dirs_nbs:
147 for d, name in self.dirs_nbs:
150 d = d.replace('/', os.sep)
148 d = d.replace('/', os.sep)
151 # create a notebook
149 # create a notebook
152 with io.open(pjoin(nbdir, d, '%s.ipynb' % name), 'w',
150 with io.open(pjoin(nbdir, d, '%s.ipynb' % name), 'w',
153 encoding='utf-8') as f:
151 encoding='utf-8') as f:
154 nb = new_notebook()
152 nb = new_notebook()
155 write(nb, f, version=4)
153 write(nb, f, version=4)
156
154
157 # create a text file
155 # create a text file
158 with io.open(pjoin(nbdir, d, '%s.txt' % name), 'w',
156 with io.open(pjoin(nbdir, d, '%s.txt' % name), 'w',
159 encoding='utf-8') as f:
157 encoding='utf-8') as f:
160 f.write(self._txt_for_name(name))
158 f.write(self._txt_for_name(name))
161
159
162 # create a binary file
160 # create a binary file
163 with io.open(pjoin(nbdir, d, '%s.blob' % name), 'wb') as f:
161 with io.open(pjoin(nbdir, d, '%s.blob' % name), 'wb') as f:
164 f.write(self._blob_for_name(name))
162 f.write(self._blob_for_name(name))
165
163
166 self.api = API(self.base_url())
164 self.api = API(self.base_url())
167
165
168 def tearDown(self):
166 def tearDown(self):
169 nbdir = self.notebook_dir.name
167 nbdir = self.notebook_dir.name
170
168
171 for dname in (list(self.top_level_dirs) + self.hidden_dirs):
169 for dname in (list(self.top_level_dirs) + self.hidden_dirs):
172 shutil.rmtree(pjoin(nbdir, dname), ignore_errors=True)
170 shutil.rmtree(pjoin(nbdir, dname), ignore_errors=True)
173
171
174 if os.path.isfile(pjoin(nbdir, 'inroot.ipynb')):
172 if os.path.isfile(pjoin(nbdir, 'inroot.ipynb')):
175 os.unlink(pjoin(nbdir, 'inroot.ipynb'))
173 os.unlink(pjoin(nbdir, 'inroot.ipynb'))
176
174
177 def test_list_notebooks(self):
175 def test_list_notebooks(self):
178 nbs = notebooks_only(self.api.list().json())
176 nbs = notebooks_only(self.api.list().json())
179 self.assertEqual(len(nbs), 1)
177 self.assertEqual(len(nbs), 1)
180 self.assertEqual(nbs[0]['name'], 'inroot.ipynb')
178 self.assertEqual(nbs[0]['name'], 'inroot.ipynb')
181
179
182 nbs = notebooks_only(self.api.list('/Directory with spaces in/').json())
180 nbs = notebooks_only(self.api.list('/Directory with spaces in/').json())
183 self.assertEqual(len(nbs), 1)
181 self.assertEqual(len(nbs), 1)
184 self.assertEqual(nbs[0]['name'], 'inspace.ipynb')
182 self.assertEqual(nbs[0]['name'], 'inspace.ipynb')
185
183
186 nbs = notebooks_only(self.api.list(u'/unicodΓ©/').json())
184 nbs = notebooks_only(self.api.list(u'/unicodΓ©/').json())
187 self.assertEqual(len(nbs), 1)
185 self.assertEqual(len(nbs), 1)
188 self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
186 self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
189 self.assertEqual(nbs[0]['path'], u'unicodΓ©/innonascii.ipynb')
187 self.assertEqual(nbs[0]['path'], u'unicodΓ©/innonascii.ipynb')
190
188
191 nbs = notebooks_only(self.api.list('/foo/bar/').json())
189 nbs = notebooks_only(self.api.list('/foo/bar/').json())
192 self.assertEqual(len(nbs), 1)
190 self.assertEqual(len(nbs), 1)
193 self.assertEqual(nbs[0]['name'], 'baz.ipynb')
191 self.assertEqual(nbs[0]['name'], 'baz.ipynb')
194 self.assertEqual(nbs[0]['path'], 'foo/bar/baz.ipynb')
192 self.assertEqual(nbs[0]['path'], 'foo/bar/baz.ipynb')
195
193
196 nbs = notebooks_only(self.api.list('foo').json())
194 nbs = notebooks_only(self.api.list('foo').json())
197 self.assertEqual(len(nbs), 4)
195 self.assertEqual(len(nbs), 4)
198 nbnames = { normalize('NFC', n['name']) for n in nbs }
196 nbnames = { normalize('NFC', n['name']) for n in nbs }
199 expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodΓ©.ipynb']
197 expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodΓ©.ipynb']
200 expected = { normalize('NFC', name) for name in expected }
198 expected = { normalize('NFC', name) for name in expected }
201 self.assertEqual(nbnames, expected)
199 self.assertEqual(nbnames, expected)
202
200
203 nbs = notebooks_only(self.api.list('ordering').json())
201 nbs = notebooks_only(self.api.list('ordering').json())
204 nbnames = [n['name'] for n in nbs]
202 nbnames = [n['name'] for n in nbs]
205 expected = ['A.ipynb', 'b.ipynb', 'C.ipynb']
203 expected = ['A.ipynb', 'b.ipynb', 'C.ipynb']
206 self.assertEqual(nbnames, expected)
204 self.assertEqual(nbnames, expected)
207
205
208 def test_list_dirs(self):
206 def test_list_dirs(self):
209 print(self.api.list().json())
207 print(self.api.list().json())
210 dirs = dirs_only(self.api.list().json())
208 dirs = dirs_only(self.api.list().json())
211 dir_names = {normalize('NFC', d['name']) for d in dirs}
209 dir_names = {normalize('NFC', d['name']) for d in dirs}
212 print(dir_names)
210 print(dir_names)
213 print(self.top_level_dirs)
211 print(self.top_level_dirs)
214 self.assertEqual(dir_names, self.top_level_dirs) # Excluding hidden dirs
212 self.assertEqual(dir_names, self.top_level_dirs) # Excluding hidden dirs
215
213
216 def test_list_nonexistant_dir(self):
214 def test_list_nonexistant_dir(self):
217 with assert_http_error(404):
215 with assert_http_error(404):
218 self.api.list('nonexistant')
216 self.api.list('nonexistant')
219
217
220 def test_get_nb_contents(self):
218 def test_get_nb_contents(self):
221 for d, name in self.dirs_nbs:
219 for d, name in self.dirs_nbs:
222 path = url_path_join(d, name + '.ipynb')
220 path = url_path_join(d, name + '.ipynb')
223 nb = self.api.read(path).json()
221 nb = self.api.read(path).json()
224 self.assertEqual(nb['name'], u'%s.ipynb' % name)
222 self.assertEqual(nb['name'], u'%s.ipynb' % name)
225 self.assertEqual(nb['path'], path)
223 self.assertEqual(nb['path'], path)
226 self.assertEqual(nb['type'], 'notebook')
224 self.assertEqual(nb['type'], 'notebook')
227 self.assertIn('content', nb)
225 self.assertIn('content', nb)
228 self.assertEqual(nb['format'], 'json')
226 self.assertEqual(nb['format'], 'json')
229 self.assertIn('content', nb)
227 self.assertIn('content', nb)
230 self.assertIn('metadata', nb['content'])
228 self.assertIn('metadata', nb['content'])
231 self.assertIsInstance(nb['content']['metadata'], dict)
229 self.assertIsInstance(nb['content']['metadata'], dict)
232
230
233 def test_get_contents_no_such_file(self):
231 def test_get_contents_no_such_file(self):
234 # Name that doesn't exist - should be a 404
232 # Name that doesn't exist - should be a 404
235 with assert_http_error(404):
233 with assert_http_error(404):
236 self.api.read('foo/q.ipynb')
234 self.api.read('foo/q.ipynb')
237
235
238 def test_get_text_file_contents(self):
236 def test_get_text_file_contents(self):
239 for d, name in self.dirs_nbs:
237 for d, name in self.dirs_nbs:
240 path = url_path_join(d, name + '.txt')
238 path = url_path_join(d, name + '.txt')
241 model = self.api.read(path).json()
239 model = self.api.read(path).json()
242 self.assertEqual(model['name'], u'%s.txt' % name)
240 self.assertEqual(model['name'], u'%s.txt' % name)
243 self.assertEqual(model['path'], path)
241 self.assertEqual(model['path'], path)
244 self.assertIn('content', model)
242 self.assertIn('content', model)
245 self.assertEqual(model['format'], 'text')
243 self.assertEqual(model['format'], 'text')
246 self.assertEqual(model['type'], 'file')
244 self.assertEqual(model['type'], 'file')
247 self.assertEqual(model['content'], self._txt_for_name(name))
245 self.assertEqual(model['content'], self._txt_for_name(name))
248
246
249 # Name that doesn't exist - should be a 404
247 # Name that doesn't exist - should be a 404
250 with assert_http_error(404):
248 with assert_http_error(404):
251 self.api.read('foo/q.txt')
249 self.api.read('foo/q.txt')
252
250
253 # Specifying format=text should fail on a non-UTF-8 file
251 # Specifying format=text should fail on a non-UTF-8 file
254 with assert_http_error(400):
252 with assert_http_error(400):
255 self.api.read('foo/bar/baz.blob', type_='file', format='text')
253 self.api.read('foo/bar/baz.blob', type_='file', format='text')
256
254
257 def test_get_binary_file_contents(self):
255 def test_get_binary_file_contents(self):
258 for d, name in self.dirs_nbs:
256 for d, name in self.dirs_nbs:
259 path = url_path_join(d, name + '.blob')
257 path = url_path_join(d, name + '.blob')
260 model = self.api.read(path).json()
258 model = self.api.read(path).json()
261 self.assertEqual(model['name'], u'%s.blob' % name)
259 self.assertEqual(model['name'], u'%s.blob' % name)
262 self.assertEqual(model['path'], path)
260 self.assertEqual(model['path'], path)
263 self.assertIn('content', model)
261 self.assertIn('content', model)
264 self.assertEqual(model['format'], 'base64')
262 self.assertEqual(model['format'], 'base64')
265 self.assertEqual(model['type'], 'file')
263 self.assertEqual(model['type'], 'file')
266 b64_data = base64.encodestring(self._blob_for_name(name)).decode('ascii')
264 b64_data = base64.encodestring(self._blob_for_name(name)).decode('ascii')
267 self.assertEqual(model['content'], b64_data)
265 self.assertEqual(model['content'], b64_data)
268
266
269 # Name that doesn't exist - should be a 404
267 # Name that doesn't exist - should be a 404
270 with assert_http_error(404):
268 with assert_http_error(404):
271 self.api.read('foo/q.txt')
269 self.api.read('foo/q.txt')
272
270
273 def test_get_bad_type(self):
271 def test_get_bad_type(self):
274 with assert_http_error(400):
272 with assert_http_error(400):
275 self.api.read(u'unicodΓ©', type_='file') # this is a directory
273 self.api.read(u'unicodΓ©', type_='file') # this is a directory
276
274
277 with assert_http_error(400):
275 with assert_http_error(400):
278 self.api.read(u'unicodΓ©/innonascii.ipynb', type_='directory')
276 self.api.read(u'unicodΓ©/innonascii.ipynb', type_='directory')
279
277
280 def _check_created(self, resp, path, type='notebook'):
278 def _check_created(self, resp, path, type='notebook'):
281 self.assertEqual(resp.status_code, 201)
279 self.assertEqual(resp.status_code, 201)
282 location_header = py3compat.str_to_unicode(resp.headers['Location'])
280 location_header = py3compat.str_to_unicode(resp.headers['Location'])
283 self.assertEqual(location_header, url_escape(url_path_join(u'/api/contents', path)))
281 self.assertEqual(location_header, url_escape(url_path_join(u'/api/contents', path)))
284 rjson = resp.json()
282 rjson = resp.json()
285 self.assertEqual(rjson['name'], path.rsplit('/', 1)[-1])
283 self.assertEqual(rjson['name'], path.rsplit('/', 1)[-1])
286 self.assertEqual(rjson['path'], path)
284 self.assertEqual(rjson['path'], path)
287 self.assertEqual(rjson['type'], type)
285 self.assertEqual(rjson['type'], type)
288 isright = os.path.isdir if type == 'directory' else os.path.isfile
286 isright = os.path.isdir if type == 'directory' else os.path.isfile
289 assert isright(pjoin(
287 assert isright(pjoin(
290 self.notebook_dir.name,
288 self.notebook_dir.name,
291 path.replace('/', os.sep),
289 path.replace('/', os.sep),
292 ))
290 ))
293
291
294 def test_create_untitled(self):
292 def test_create_untitled(self):
295 resp = self.api.create_untitled(path=u'Γ₯ b')
293 resp = self.api.create_untitled(path=u'Γ₯ b')
296 self._check_created(resp, u'Γ₯ b/Untitled0.ipynb')
294 self._check_created(resp, u'Γ₯ b/Untitled0.ipynb')
297
295
298 # Second time
296 # Second time
299 resp = self.api.create_untitled(path=u'Γ₯ b')
297 resp = self.api.create_untitled(path=u'Γ₯ b')
300 self._check_created(resp, u'Γ₯ b/Untitled1.ipynb')
298 self._check_created(resp, u'Γ₯ b/Untitled1.ipynb')
301
299
302 # And two directories down
300 # And two directories down
303 resp = self.api.create_untitled(path='foo/bar')
301 resp = self.api.create_untitled(path='foo/bar')
304 self._check_created(resp, 'foo/bar/Untitled0.ipynb')
302 self._check_created(resp, 'foo/bar/Untitled0.ipynb')
305
303
306 def test_create_untitled_txt(self):
304 def test_create_untitled_txt(self):
307 resp = self.api.create_untitled(path='foo/bar', ext='.txt')
305 resp = self.api.create_untitled(path='foo/bar', ext='.txt')
308 self._check_created(resp, 'foo/bar/untitled0.txt', type='file')
306 self._check_created(resp, 'foo/bar/untitled0.txt', type='file')
309
307
310 resp = self.api.read(path='foo/bar/untitled0.txt')
308 resp = self.api.read(path='foo/bar/untitled0.txt')
311 model = resp.json()
309 model = resp.json()
312 self.assertEqual(model['type'], 'file')
310 self.assertEqual(model['type'], 'file')
313 self.assertEqual(model['format'], 'text')
311 self.assertEqual(model['format'], 'text')
314 self.assertEqual(model['content'], '')
312 self.assertEqual(model['content'], '')
315
313
316 def test_upload(self):
314 def test_upload(self):
317 nb = new_notebook()
315 nb = new_notebook()
318 nbmodel = {'content': nb, 'type': 'notebook'}
316 nbmodel = {'content': nb, 'type': 'notebook'}
319 path = u'Γ₯ b/Upload tΓ©st.ipynb'
317 path = u'Γ₯ b/Upload tΓ©st.ipynb'
320 resp = self.api.upload(path, body=json.dumps(nbmodel))
318 resp = self.api.upload(path, body=json.dumps(nbmodel))
321 self._check_created(resp, path)
319 self._check_created(resp, path)
322
320
323 def test_mkdir_untitled(self):
321 def test_mkdir_untitled(self):
324 resp = self.api.mkdir_untitled(path=u'Γ₯ b')
322 resp = self.api.mkdir_untitled(path=u'Γ₯ b')
325 self._check_created(resp, u'Γ₯ b/Untitled Folder0', type='directory')
323 self._check_created(resp, u'Γ₯ b/Untitled Folder0', type='directory')
326
324
327 # Second time
325 # Second time
328 resp = self.api.mkdir_untitled(path=u'Γ₯ b')
326 resp = self.api.mkdir_untitled(path=u'Γ₯ b')
329 self._check_created(resp, u'Γ₯ b/Untitled Folder1', type='directory')
327 self._check_created(resp, u'Γ₯ b/Untitled Folder1', type='directory')
330
328
331 # And two directories down
329 # And two directories down
332 resp = self.api.mkdir_untitled(path='foo/bar')
330 resp = self.api.mkdir_untitled(path='foo/bar')
333 self._check_created(resp, 'foo/bar/Untitled Folder0', type='directory')
331 self._check_created(resp, 'foo/bar/Untitled Folder0', type='directory')
334
332
335 def test_mkdir(self):
333 def test_mkdir(self):
336 path = u'Γ₯ b/New βˆ‚ir'
334 path = u'Γ₯ b/New βˆ‚ir'
337 resp = self.api.mkdir(path)
335 resp = self.api.mkdir(path)
338 self._check_created(resp, path, type='directory')
336 self._check_created(resp, path, type='directory')
339
337
340 def test_mkdir_hidden_400(self):
338 def test_mkdir_hidden_400(self):
341 with assert_http_error(400):
339 with assert_http_error(400):
342 resp = self.api.mkdir(u'Γ₯ b/.hidden')
340 resp = self.api.mkdir(u'Γ₯ b/.hidden')
343
341
344 def test_upload_txt(self):
342 def test_upload_txt(self):
345 body = u'ΓΌnicode tΓ©xt'
343 body = u'ΓΌnicode tΓ©xt'
346 model = {
344 model = {
347 'content' : body,
345 'content' : body,
348 'format' : 'text',
346 'format' : 'text',
349 'type' : 'file',
347 'type' : 'file',
350 }
348 }
351 path = u'Γ₯ b/Upload tΓ©st.txt'
349 path = u'Γ₯ b/Upload tΓ©st.txt'
352 resp = self.api.upload(path, body=json.dumps(model))
350 resp = self.api.upload(path, body=json.dumps(model))
353
351
354 # check roundtrip
352 # check roundtrip
355 resp = self.api.read(path)
353 resp = self.api.read(path)
356 model = resp.json()
354 model = resp.json()
357 self.assertEqual(model['type'], 'file')
355 self.assertEqual(model['type'], 'file')
358 self.assertEqual(model['format'], 'text')
356 self.assertEqual(model['format'], 'text')
359 self.assertEqual(model['content'], body)
357 self.assertEqual(model['content'], body)
360
358
361 def test_upload_b64(self):
359 def test_upload_b64(self):
362 body = b'\xFFblob'
360 body = b'\xFFblob'
363 b64body = base64.encodestring(body).decode('ascii')
361 b64body = base64.encodestring(body).decode('ascii')
364 model = {
362 model = {
365 'content' : b64body,
363 'content' : b64body,
366 'format' : 'base64',
364 'format' : 'base64',
367 'type' : 'file',
365 'type' : 'file',
368 }
366 }
369 path = u'Γ₯ b/Upload tΓ©st.blob'
367 path = u'Γ₯ b/Upload tΓ©st.blob'
370 resp = self.api.upload(path, body=json.dumps(model))
368 resp = self.api.upload(path, body=json.dumps(model))
371
369
372 # check roundtrip
370 # check roundtrip
373 resp = self.api.read(path)
371 resp = self.api.read(path)
374 model = resp.json()
372 model = resp.json()
375 self.assertEqual(model['type'], 'file')
373 self.assertEqual(model['type'], 'file')
376 self.assertEqual(model['path'], path)
374 self.assertEqual(model['path'], path)
377 self.assertEqual(model['format'], 'base64')
375 self.assertEqual(model['format'], 'base64')
378 decoded = base64.decodestring(model['content'].encode('ascii'))
376 decoded = base64.decodestring(model['content'].encode('ascii'))
379 self.assertEqual(decoded, body)
377 self.assertEqual(decoded, body)
380
378
381 def test_upload_v2(self):
379 def test_upload_v2(self):
382 nb = v2.new_notebook()
380 nb = v2.new_notebook()
383 ws = v2.new_worksheet()
381 ws = v2.new_worksheet()
384 nb.worksheets.append(ws)
382 nb.worksheets.append(ws)
385 ws.cells.append(v2.new_code_cell(input='print("hi")'))
383 ws.cells.append(v2.new_code_cell(input='print("hi")'))
386 nbmodel = {'content': nb, 'type': 'notebook'}
384 nbmodel = {'content': nb, 'type': 'notebook'}
387 path = u'Γ₯ b/Upload tΓ©st.ipynb'
385 path = u'Γ₯ b/Upload tΓ©st.ipynb'
388 resp = self.api.upload(path, body=json.dumps(nbmodel))
386 resp = self.api.upload(path, body=json.dumps(nbmodel))
389 self._check_created(resp, path)
387 self._check_created(resp, path)
390 resp = self.api.read(path)
388 resp = self.api.read(path)
391 data = resp.json()
389 data = resp.json()
392 self.assertEqual(data['content']['nbformat'], 4)
390 self.assertEqual(data['content']['nbformat'], 4)
393
391
394 def test_copy(self):
392 def test_copy(self):
395 resp = self.api.copy(u'Γ₯ b/Γ§ d.ipynb', u'unicodΓ©')
393 resp = self.api.copy(u'Γ₯ b/Γ§ d.ipynb', u'unicodΓ©')
396 self._check_created(resp, u'unicodΓ©/Γ§ d-Copy0.ipynb')
394 self._check_created(resp, u'unicodΓ©/Γ§ d-Copy0.ipynb')
397
395
398 resp = self.api.copy(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b')
396 resp = self.api.copy(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b')
399 self._check_created(resp, u'Γ₯ b/Γ§ d-Copy0.ipynb')
397 self._check_created(resp, u'Γ₯ b/Γ§ d-Copy0.ipynb')
400
398
401 def test_copy_path(self):
399 def test_copy_path(self):
402 resp = self.api.copy(u'foo/a.ipynb', u'Γ₯ b')
400 resp = self.api.copy(u'foo/a.ipynb', u'Γ₯ b')
403 self._check_created(resp, u'Γ₯ b/a-Copy0.ipynb')
401 self._check_created(resp, u'Γ₯ b/a-Copy0.ipynb')
404
402
405 def test_copy_put_400(self):
403 def test_copy_put_400(self):
406 with assert_http_error(400):
404 with assert_http_error(400):
407 resp = self.api.copy_put(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b/cΓΈpy.ipynb')
405 resp = self.api.copy_put(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b/cΓΈpy.ipynb')
408
406
409 def test_copy_dir_400(self):
407 def test_copy_dir_400(self):
410 # can't copy directories
408 # can't copy directories
411 with assert_http_error(400):
409 with assert_http_error(400):
412 resp = self.api.copy(u'Γ₯ b', u'foo')
410 resp = self.api.copy(u'Γ₯ b', u'foo')
413
411
414 def test_delete(self):
412 def test_delete(self):
415 for d, name in self.dirs_nbs:
413 for d, name in self.dirs_nbs:
416 print('%r, %r' % (d, name))
414 print('%r, %r' % (d, name))
417 resp = self.api.delete(url_path_join(d, name + '.ipynb'))
415 resp = self.api.delete(url_path_join(d, name + '.ipynb'))
418 self.assertEqual(resp.status_code, 204)
416 self.assertEqual(resp.status_code, 204)
419
417
420 for d in self.dirs + ['/']:
418 for d in self.dirs + ['/']:
421 nbs = notebooks_only(self.api.list(d).json())
419 nbs = notebooks_only(self.api.list(d).json())
422 print('------')
420 print('------')
423 print(d)
421 print(d)
424 print(nbs)
422 print(nbs)
425 self.assertEqual(nbs, [])
423 self.assertEqual(nbs, [])
426
424
427 def test_delete_dirs(self):
425 def test_delete_dirs(self):
428 # depth-first delete everything, so we don't try to delete empty directories
426 # depth-first delete everything, so we don't try to delete empty directories
429 for name in sorted(self.dirs + ['/'], key=len, reverse=True):
427 for name in sorted(self.dirs + ['/'], key=len, reverse=True):
430 listing = self.api.list(name).json()['content']
428 listing = self.api.list(name).json()['content']
431 for model in listing:
429 for model in listing:
432 self.api.delete(model['path'])
430 self.api.delete(model['path'])
433 listing = self.api.list('/').json()['content']
431 listing = self.api.list('/').json()['content']
434 self.assertEqual(listing, [])
432 self.assertEqual(listing, [])
435
433
436 def test_delete_non_empty_dir(self):
434 def test_delete_non_empty_dir(self):
437 """delete non-empty dir raises 400"""
435 """delete non-empty dir raises 400"""
438 with assert_http_error(400):
436 with assert_http_error(400):
439 self.api.delete(u'Γ₯ b')
437 self.api.delete(u'Γ₯ b')
440
438
441 def test_rename(self):
439 def test_rename(self):
442 resp = self.api.rename('foo/a.ipynb', 'foo/z.ipynb')
440 resp = self.api.rename('foo/a.ipynb', 'foo/z.ipynb')
443 self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb')
441 self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb')
444 self.assertEqual(resp.json()['name'], 'z.ipynb')
442 self.assertEqual(resp.json()['name'], 'z.ipynb')
445 self.assertEqual(resp.json()['path'], 'foo/z.ipynb')
443 self.assertEqual(resp.json()['path'], 'foo/z.ipynb')
446 assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb'))
444 assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb'))
447
445
448 nbs = notebooks_only(self.api.list('foo').json())
446 nbs = notebooks_only(self.api.list('foo').json())
449 nbnames = set(n['name'] for n in nbs)
447 nbnames = set(n['name'] for n in nbs)
450 self.assertIn('z.ipynb', nbnames)
448 self.assertIn('z.ipynb', nbnames)
451 self.assertNotIn('a.ipynb', nbnames)
449 self.assertNotIn('a.ipynb', nbnames)
452
450
453 def test_rename_existing(self):
451 def test_rename_existing(self):
454 with assert_http_error(409):
452 with assert_http_error(409):
455 self.api.rename('foo/a.ipynb', 'foo/b.ipynb')
453 self.api.rename('foo/a.ipynb', 'foo/b.ipynb')
456
454
457 def test_save(self):
455 def test_save(self):
458 resp = self.api.read('foo/a.ipynb')
456 resp = self.api.read('foo/a.ipynb')
459 nbcontent = json.loads(resp.text)['content']
457 nbcontent = json.loads(resp.text)['content']
460 nb = from_dict(nbcontent)
458 nb = from_dict(nbcontent)
461 nb.cells.append(new_markdown_cell(u'Created by test Β³'))
459 nb.cells.append(new_markdown_cell(u'Created by test Β³'))
462
460
463 nbmodel= {'content': nb, 'type': 'notebook'}
461 nbmodel= {'content': nb, 'type': 'notebook'}
464 resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
462 resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
465
463
466 nbfile = pjoin(self.notebook_dir.name, 'foo', 'a.ipynb')
464 nbfile = pjoin(self.notebook_dir.name, 'foo', 'a.ipynb')
467 with io.open(nbfile, 'r', encoding='utf-8') as f:
465 with io.open(nbfile, 'r', encoding='utf-8') as f:
468 newnb = read(f, as_version=4)
466 newnb = read(f, as_version=4)
469 self.assertEqual(newnb.cells[0].source,
467 self.assertEqual(newnb.cells[0].source,
470 u'Created by test Β³')
468 u'Created by test Β³')
471 nbcontent = self.api.read('foo/a.ipynb').json()['content']
469 nbcontent = self.api.read('foo/a.ipynb').json()['content']
472 newnb = from_dict(nbcontent)
470 newnb = from_dict(nbcontent)
473 self.assertEqual(newnb.cells[0].source,
471 self.assertEqual(newnb.cells[0].source,
474 u'Created by test Β³')
472 u'Created by test Β³')
475
473
476
474
477 def test_checkpoints(self):
475 def test_checkpoints(self):
478 resp = self.api.read('foo/a.ipynb')
476 resp = self.api.read('foo/a.ipynb')
479 r = self.api.new_checkpoint('foo/a.ipynb')
477 r = self.api.new_checkpoint('foo/a.ipynb')
480 self.assertEqual(r.status_code, 201)
478 self.assertEqual(r.status_code, 201)
481 cp1 = r.json()
479 cp1 = r.json()
482 self.assertEqual(set(cp1), {'id', 'last_modified'})
480 self.assertEqual(set(cp1), {'id', 'last_modified'})
483 self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id'])
481 self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id'])
484
482
485 # Modify it
483 # Modify it
486 nbcontent = json.loads(resp.text)['content']
484 nbcontent = json.loads(resp.text)['content']
487 nb = from_dict(nbcontent)
485 nb = from_dict(nbcontent)
488 hcell = new_markdown_cell('Created by test')
486 hcell = new_markdown_cell('Created by test')
489 nb.cells.append(hcell)
487 nb.cells.append(hcell)
490 # Save
488 # Save
491 nbmodel= {'content': nb, 'type': 'notebook'}
489 nbmodel= {'content': nb, 'type': 'notebook'}
492 resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
490 resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
493
491
494 # List checkpoints
492 # List checkpoints
495 cps = self.api.get_checkpoints('foo/a.ipynb').json()
493 cps = self.api.get_checkpoints('foo/a.ipynb').json()
496 self.assertEqual(cps, [cp1])
494 self.assertEqual(cps, [cp1])
497
495
498 nbcontent = self.api.read('foo/a.ipynb').json()['content']
496 nbcontent = self.api.read('foo/a.ipynb').json()['content']
499 nb = from_dict(nbcontent)
497 nb = from_dict(nbcontent)
500 self.assertEqual(nb.cells[0].source, 'Created by test')
498 self.assertEqual(nb.cells[0].source, 'Created by test')
501
499
502 # Restore cp1
500 # Restore cp1
503 r = self.api.restore_checkpoint('foo/a.ipynb', cp1['id'])
501 r = self.api.restore_checkpoint('foo/a.ipynb', cp1['id'])
504 self.assertEqual(r.status_code, 204)
502 self.assertEqual(r.status_code, 204)
505 nbcontent = self.api.read('foo/a.ipynb').json()['content']
503 nbcontent = self.api.read('foo/a.ipynb').json()['content']
506 nb = from_dict(nbcontent)
504 nb = from_dict(nbcontent)
507 self.assertEqual(nb.cells, [])
505 self.assertEqual(nb.cells, [])
508
506
509 # Delete cp1
507 # Delete cp1
510 r = self.api.delete_checkpoint('foo/a.ipynb', cp1['id'])
508 r = self.api.delete_checkpoint('foo/a.ipynb', cp1['id'])
511 self.assertEqual(r.status_code, 204)
509 self.assertEqual(r.status_code, 204)
512 cps = self.api.get_checkpoints('foo/a.ipynb').json()
510 cps = self.api.get_checkpoints('foo/a.ipynb').json()
513 self.assertEqual(cps, [])
511 self.assertEqual(cps, [])
@@ -1,267 +1,267 b''
1 // Copyright (c) IPython Development Team.
1 // Copyright (c) IPython Development Team.
2 // Distributed under the terms of the Modified BSD License.
2 // Distributed under the terms of the Modified BSD License.
3
3
4 define([
4 define([
5 'base/js/namespace',
5 'base/js/namespace',
6 'jquery',
6 'jquery',
7 'base/js/utils',
7 'base/js/utils',
8 ], function(IPython, $, utils) {
8 ], function(IPython, $, utils) {
9 var Contents = function(options) {
9 var Contents = function(options) {
10 // Constructor
10 // Constructor
11 //
11 //
12 // A contents handles passing file operations
12 // A contents handles passing file operations
13 // to the back-end. This includes checkpointing
13 // to the back-end. This includes checkpointing
14 // with the normal file operations.
14 // with the normal file operations.
15 //
15 //
16 // Parameters:
16 // Parameters:
17 // options: dictionary
17 // options: dictionary
18 // Dictionary of keyword arguments.
18 // Dictionary of keyword arguments.
19 // base_url: string
19 // base_url: string
20 this.base_url = options.base_url;
20 this.base_url = options.base_url;
21 };
21 };
22
22
23 /** Error type */
23 /** Error type */
24 Contents.DIRECTORY_NOT_EMPTY_ERROR = 'DirectoryNotEmptyError';
24 Contents.DIRECTORY_NOT_EMPTY_ERROR = 'DirectoryNotEmptyError';
25
25
26 Contents.DirectoryNotEmptyError = function() {
26 Contents.DirectoryNotEmptyError = function() {
27 // Constructor
27 // Constructor
28 //
28 //
29 // An error representing the result of attempting to delete a non-empty
29 // An error representing the result of attempting to delete a non-empty
30 // directory.
30 // directory.
31 this.message = 'A directory must be empty before being deleted.';
31 this.message = 'A directory must be empty before being deleted.';
32 };
32 };
33
33
34 Contents.DirectoryNotEmptyError.prototype = Object.create(Error.prototype);
34 Contents.DirectoryNotEmptyError.prototype = Object.create(Error.prototype);
35 Contents.DirectoryNotEmptyError.prototype.name =
35 Contents.DirectoryNotEmptyError.prototype.name =
36 Contents.DIRECTORY_NOT_EMPTY_ERROR;
36 Contents.DIRECTORY_NOT_EMPTY_ERROR;
37
37
38
38
39 Contents.prototype.api_url = function() {
39 Contents.prototype.api_url = function() {
40 var url_parts = [this.base_url, 'api/contents'].concat(
40 var url_parts = [this.base_url, 'api/contents'].concat(
41 Array.prototype.slice.apply(arguments));
41 Array.prototype.slice.apply(arguments));
42 return utils.url_join_encode.apply(null, url_parts);
42 return utils.url_join_encode.apply(null, url_parts);
43 };
43 };
44
44
45 /**
45 /**
46 * Creates a basic error handler that wraps a jqXHR error as an Error.
46 * Creates a basic error handler that wraps a jqXHR error as an Error.
47 *
47 *
48 * Takes a callback that accepts an Error, and returns a callback that can
48 * Takes a callback that accepts an Error, and returns a callback that can
49 * be passed directly to $.ajax, which will wrap the error from jQuery
49 * be passed directly to $.ajax, which will wrap the error from jQuery
50 * as an Error, and pass that to the original callback.
50 * as an Error, and pass that to the original callback.
51 *
51 *
52 * @method create_basic_error_handler
52 * @method create_basic_error_handler
53 * @param{Function} callback
53 * @param{Function} callback
54 * @return{Function}
54 * @return{Function}
55 */
55 */
56 Contents.prototype.create_basic_error_handler = function(callback) {
56 Contents.prototype.create_basic_error_handler = function(callback) {
57 if (!callback) {
57 if (!callback) {
58 return utils.log_ajax_error;
58 return utils.log_ajax_error;
59 }
59 }
60 return function(xhr, status, error) {
60 return function(xhr, status, error) {
61 callback(utils.wrap_ajax_error(xhr, status, error));
61 callback(utils.wrap_ajax_error(xhr, status, error));
62 };
62 };
63 };
63 };
64
64
65 /**
65 /**
66 * File Functions (including notebook operations)
66 * File Functions (including notebook operations)
67 */
67 */
68
68
69 /**
69 /**
70 * Get a file.
70 * Get a file.
71 *
71 *
72 * Calls success with file JSON model, or error with error.
72 * Calls success with file JSON model, or error with error.
73 *
73 *
74 * @method get
74 * @method get
75 * @param {String} path
75 * @param {String} path
76 * @param {Function} success
76 * @param {Function} success
77 * @param {Function} error
77 * @param {Function} error
78 */
78 */
79 Contents.prototype.get = function (path, options) {
79 Contents.prototype.get = function (path, options) {
80 // We do the call with settings so we can set cache to false.
80 // We do the call with settings so we can set cache to false.
81 var settings = {
81 var settings = {
82 processData : false,
82 processData : false,
83 cache : false,
83 cache : false,
84 type : "GET",
84 type : "GET",
85 dataType : "json",
85 dataType : "json",
86 success : options.success,
86 success : options.success,
87 error : this.create_basic_error_handler(options.error)
87 error : this.create_basic_error_handler(options.error)
88 };
88 };
89 var url = this.api_url(path);
89 var url = this.api_url(path);
90 if (options.type) {
90 params = {};
91 url += '?type=' + options.type;
91 if (options.type) { params.type = options.type; }
92 }
92 if (options.format) { params.format = options.format; }
93 $.ajax(url, settings);
93 $.ajax(url + '?' + $.param(params), settings);
94 };
94 };
95
95
96
96
97 /**
97 /**
98 * Creates a new untitled file or directory in the specified directory path.
98 * Creates a new untitled file or directory in the specified directory path.
99 *
99 *
100 * @method new
100 * @method new
101 * @param {String} path: the directory in which to create the new file/directory
101 * @param {String} path: the directory in which to create the new file/directory
102 * @param {Object} options:
102 * @param {Object} options:
103 * ext: file extension to use
103 * ext: file extension to use
104 * type: model type to create ('notebook', 'file', or 'directory')
104 * type: model type to create ('notebook', 'file', or 'directory')
105 */
105 */
106 Contents.prototype.new_untitled = function(path, options) {
106 Contents.prototype.new_untitled = function(path, options) {
107 var data = JSON.stringify({
107 var data = JSON.stringify({
108 ext: options.ext,
108 ext: options.ext,
109 type: options.type
109 type: options.type
110 });
110 });
111
111
112 var settings = {
112 var settings = {
113 processData : false,
113 processData : false,
114 type : "POST",
114 type : "POST",
115 data: data,
115 data: data,
116 dataType : "json",
116 dataType : "json",
117 success : options.success || function() {},
117 success : options.success || function() {},
118 error : this.create_basic_error_handler(options.error)
118 error : this.create_basic_error_handler(options.error)
119 };
119 };
120 if (options.extra_settings) {
120 if (options.extra_settings) {
121 $.extend(settings, options.extra_settings);
121 $.extend(settings, options.extra_settings);
122 }
122 }
123 $.ajax(this.api_url(path), settings);
123 $.ajax(this.api_url(path), settings);
124 };
124 };
125
125
126 Contents.prototype.delete = function(path, options) {
126 Contents.prototype.delete = function(path, options) {
127 var error_callback = options.error || function() {};
127 var error_callback = options.error || function() {};
128 var settings = {
128 var settings = {
129 processData : false,
129 processData : false,
130 type : "DELETE",
130 type : "DELETE",
131 dataType : "json",
131 dataType : "json",
132 success : options.success || function() {},
132 success : options.success || function() {},
133 error : function(xhr, status, error) {
133 error : function(xhr, status, error) {
134 // TODO: update IPEP27 to specify errors more precisely, so
134 // TODO: update IPEP27 to specify errors more precisely, so
135 // that error types can be detected here with certainty.
135 // that error types can be detected here with certainty.
136 if (xhr.status === 400) {
136 if (xhr.status === 400) {
137 error_callback(new Contents.DirectoryNotEmptyError());
137 error_callback(new Contents.DirectoryNotEmptyError());
138 }
138 }
139 error_callback(utils.wrap_ajax_error(xhr, status, error));
139 error_callback(utils.wrap_ajax_error(xhr, status, error));
140 }
140 }
141 };
141 };
142 var url = this.api_url(path);
142 var url = this.api_url(path);
143 $.ajax(url, settings);
143 $.ajax(url, settings);
144 };
144 };
145
145
146 Contents.prototype.rename = function(path, new_path, options) {
146 Contents.prototype.rename = function(path, new_path, options) {
147 var data = {path: new_path};
147 var data = {path: new_path};
148 var settings = {
148 var settings = {
149 processData : false,
149 processData : false,
150 type : "PATCH",
150 type : "PATCH",
151 data : JSON.stringify(data),
151 data : JSON.stringify(data),
152 dataType: "json",
152 dataType: "json",
153 contentType: 'application/json',
153 contentType: 'application/json',
154 success : options.success || function() {},
154 success : options.success || function() {},
155 error : this.create_basic_error_handler(options.error)
155 error : this.create_basic_error_handler(options.error)
156 };
156 };
157 var url = this.api_url(path);
157 var url = this.api_url(path);
158 $.ajax(url, settings);
158 $.ajax(url, settings);
159 };
159 };
160
160
161 Contents.prototype.save = function(path, model, options) {
161 Contents.prototype.save = function(path, model, options) {
162 // We do the call with settings so we can set cache to false.
162 // We do the call with settings so we can set cache to false.
163 var settings = {
163 var settings = {
164 processData : false,
164 processData : false,
165 type : "PUT",
165 type : "PUT",
166 data : JSON.stringify(model),
166 data : JSON.stringify(model),
167 contentType: 'application/json',
167 contentType: 'application/json',
168 success : options.success || function() {},
168 success : options.success || function() {},
169 error : this.create_basic_error_handler(options.error)
169 error : this.create_basic_error_handler(options.error)
170 };
170 };
171 if (options.extra_settings) {
171 if (options.extra_settings) {
172 $.extend(settings, options.extra_settings);
172 $.extend(settings, options.extra_settings);
173 }
173 }
174 var url = this.api_url(path);
174 var url = this.api_url(path);
175 $.ajax(url, settings);
175 $.ajax(url, settings);
176 };
176 };
177
177
178 Contents.prototype.copy = function(from_file, to_dir, options) {
178 Contents.prototype.copy = function(from_file, to_dir, options) {
179 // Copy a file into a given directory via POST
179 // Copy a file into a given directory via POST
180 // The server will select the name of the copied file
180 // The server will select the name of the copied file
181 var url = this.api_url(to_dir);
181 var url = this.api_url(to_dir);
182
182
183 var settings = {
183 var settings = {
184 processData : false,
184 processData : false,
185 type: "POST",
185 type: "POST",
186 data: JSON.stringify({copy_from: from_file}),
186 data: JSON.stringify({copy_from: from_file}),
187 dataType : "json",
187 dataType : "json",
188 success: options.success || function() {},
188 success: options.success || function() {},
189 error: this.create_basic_error_handler(options.error)
189 error: this.create_basic_error_handler(options.error)
190 };
190 };
191 if (options.extra_settings) {
191 if (options.extra_settings) {
192 $.extend(settings, options.extra_settings);
192 $.extend(settings, options.extra_settings);
193 }
193 }
194 $.ajax(url, settings);
194 $.ajax(url, settings);
195 };
195 };
196
196
197 /**
197 /**
198 * Checkpointing Functions
198 * Checkpointing Functions
199 */
199 */
200
200
201 Contents.prototype.create_checkpoint = function(path, options) {
201 Contents.prototype.create_checkpoint = function(path, options) {
202 var url = this.api_url(path, 'checkpoints');
202 var url = this.api_url(path, 'checkpoints');
203 var settings = {
203 var settings = {
204 type : "POST",
204 type : "POST",
205 success: options.success || function() {},
205 success: options.success || function() {},
206 error : this.create_basic_error_handler(options.error)
206 error : this.create_basic_error_handler(options.error)
207 };
207 };
208 $.ajax(url, settings);
208 $.ajax(url, settings);
209 };
209 };
210
210
211 Contents.prototype.list_checkpoints = function(path, options) {
211 Contents.prototype.list_checkpoints = function(path, options) {
212 var url = this.api_url(path, 'checkpoints');
212 var url = this.api_url(path, 'checkpoints');
213 var settings = {
213 var settings = {
214 type : "GET",
214 type : "GET",
215 success: options.success,
215 success: options.success,
216 error : this.create_basic_error_handler(options.error)
216 error : this.create_basic_error_handler(options.error)
217 };
217 };
218 $.ajax(url, settings);
218 $.ajax(url, settings);
219 };
219 };
220
220
221 Contents.prototype.restore_checkpoint = function(path, checkpoint_id, options) {
221 Contents.prototype.restore_checkpoint = function(path, checkpoint_id, options) {
222 var url = this.api_url(path, 'checkpoints', checkpoint_id);
222 var url = this.api_url(path, 'checkpoints', checkpoint_id);
223 var settings = {
223 var settings = {
224 type : "POST",
224 type : "POST",
225 success: options.success || function() {},
225 success: options.success || function() {},
226 error : this.create_basic_error_handler(options.error)
226 error : this.create_basic_error_handler(options.error)
227 };
227 };
228 $.ajax(url, settings);
228 $.ajax(url, settings);
229 };
229 };
230
230
231 Contents.prototype.delete_checkpoint = function(path, checkpoint_id, options) {
231 Contents.prototype.delete_checkpoint = function(path, checkpoint_id, options) {
232 var url = this.api_url(path, 'checkpoints', checkpoint_id);
232 var url = this.api_url(path, 'checkpoints', checkpoint_id);
233 var settings = {
233 var settings = {
234 type : "DELETE",
234 type : "DELETE",
235 success: options.success || function() {},
235 success: options.success || function() {},
236 error : this.create_basic_error_handler(options.error)
236 error : this.create_basic_error_handler(options.error)
237 };
237 };
238 $.ajax(url, settings);
238 $.ajax(url, settings);
239 };
239 };
240
240
241 /**
241 /**
242 * File management functions
242 * File management functions
243 */
243 */
244
244
245 /**
245 /**
246 * List notebooks and directories at a given path
246 * List notebooks and directories at a given path
247 *
247 *
248 * On success, load_callback is called with an array of dictionaries
248 * On success, load_callback is called with an array of dictionaries
249 * representing individual files or directories. Each dictionary has
249 * representing individual files or directories. Each dictionary has
250 * the keys:
250 * the keys:
251 * type: "notebook" or "directory"
251 * type: "notebook" or "directory"
252 * created: created date
252 * created: created date
253 * last_modified: last modified dat
253 * last_modified: last modified dat
254 * @method list_notebooks
254 * @method list_notebooks
255 * @param {String} path The path to list notebooks in
255 * @param {String} path The path to list notebooks in
256 * @param {Object} options including success and error callbacks
256 * @param {Object} options including success and error callbacks
257 */
257 */
258 Contents.prototype.list_contents = function(path, options) {
258 Contents.prototype.list_contents = function(path, options) {
259 options.type = 'directory';
259 options.type = 'directory';
260 this.get(path, options);
260 this.get(path, options);
261 };
261 };
262
262
263
263
264 IPython.Contents = Contents;
264 IPython.Contents = Contents;
265
265
266 return {'Contents': Contents};
266 return {'Contents': Contents};
267 });
267 });
General Comments 0
You need to be logged in to leave comments. Login now