##// END OF EJS Templates
allow requesting contents without body...
Min RK -
Show More
@@ -1,336 +1,342 b''
1 1 """Tornado handlers for the contents web service."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import json
7 7
8 8 from tornado import gen, web
9 9
10 10 from IPython.html.utils import url_path_join, url_escape
11 11 from IPython.utils.jsonutil import date_default
12 12
13 13 from IPython.html.base.handlers import (
14 14 IPythonHandler, json_errors, path_regex,
15 15 )
16 16
17 17
18 18 def sort_key(model):
19 19 """key function for case-insensitive sort by name and type"""
20 20 iname = model['name'].lower()
21 21 type_key = {
22 22 'directory' : '0',
23 23 'notebook' : '1',
24 24 'file' : '2',
25 25 }.get(model['type'], '9')
26 26 return u'%s%s' % (type_key, iname)
27 27
28 28
29 29 def validate_model(model, expect_content):
30 30 """
31 31 Validate a model returned by a ContentsManager method.
32 32
33 33 If expect_content is True, then we expect non-null entries for 'content'
34 34 and 'format'.
35 35 """
36 36 required_keys = {
37 37 "name",
38 38 "path",
39 39 "type",
40 40 "writable",
41 41 "created",
42 42 "last_modified",
43 43 "mimetype",
44 44 "content",
45 45 "format",
46 46 }
47 47 missing = required_keys - set(model.keys())
48 48 if missing:
49 49 raise web.HTTPError(
50 50 500,
51 51 u"Missing Model Keys: {missing}".format(missing=missing),
52 52 )
53 53
54 54 maybe_none_keys = ['content', 'format']
55 55 if model['type'] == 'file':
56 56 # mimetype should be populated only for file models
57 57 maybe_none_keys.append('mimetype')
58 58 if expect_content:
59 59 errors = [key for key in maybe_none_keys if model[key] is None]
60 60 if errors:
61 61 raise web.HTTPError(
62 62 500,
63 63 u"Keys unexpectedly None: {keys}".format(keys=errors),
64 64 )
65 65 else:
66 66 errors = {
67 67 key: model[key]
68 68 for key in maybe_none_keys
69 69 if model[key] is not None
70 70 }
71 71 if errors:
72 72 raise web.HTTPError(
73 73 500,
74 74 u"Keys unexpectedly not None: {keys}".format(keys=errors),
75 75 )
76 76
77 77
78 78 class ContentsHandler(IPythonHandler):
79 79
80 80 SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE')
81 81
82 82 def location_url(self, path):
83 83 """Return the full URL location of a file.
84 84
85 85 Parameters
86 86 ----------
87 87 path : unicode
88 88 The API path of the file, such as "foo/bar.txt".
89 89 """
90 90 return url_escape(url_path_join(
91 91 self.base_url, 'api', 'contents', path
92 92 ))
93 93
94 94 def _finish_model(self, model, location=True):
95 95 """Finish a JSON request with a model, setting relevant headers, etc."""
96 96 if location:
97 97 location = self.location_url(model['path'])
98 98 self.set_header('Location', location)
99 99 self.set_header('Last-Modified', model['last_modified'])
100 100 self.set_header('Content-Type', 'application/json')
101 101 self.finish(json.dumps(model, default=date_default))
102 102
103 103 @web.authenticated
104 104 @json_errors
105 105 @gen.coroutine
106 106 def get(self, path=''):
107 107 """Return a model for a file or directory.
108 108
109 109 A directory model contains a list of models (without content)
110 110 of the files and directories it contains.
111 111 """
112 112 path = path or ''
113 113 type = self.get_query_argument('type', default=None)
114 114 if type not in {None, 'directory', 'file', 'notebook'}:
115 115 raise web.HTTPError(400, u'Type %r is invalid' % type)
116 116
117 117 format = self.get_query_argument('format', default=None)
118 118 if format not in {None, 'text', 'base64'}:
119 119 raise web.HTTPError(400, u'Format %r is invalid' % format)
120 content = self.get_query_argument('content', default='1')
121 if content not in {'0', '1'}:
122 raise web.HTTPError(400, u'Content %r is invalid' % content)
123 content = int(content)
120 124
121 model = yield gen.maybe_future(self.contents_manager.get(path=path, type=type, format=format))
122 if model['type'] == 'directory':
125 model = yield gen.maybe_future(self.contents_manager.get(
126 path=path, type=type, format=format, content=content,
127 ))
128 if model['type'] == 'directory' and content:
123 129 # group listing by type, then by name (case-insensitive)
124 130 # FIXME: sorting should be done in the frontends
125 131 model['content'].sort(key=sort_key)
126 validate_model(model, expect_content=True)
132 validate_model(model, expect_content=content)
127 133 self._finish_model(model, location=False)
128 134
129 135 @web.authenticated
130 136 @json_errors
131 137 @gen.coroutine
132 138 def patch(self, path=''):
133 139 """PATCH renames a file or directory without re-uploading content."""
134 140 cm = self.contents_manager
135 141 model = self.get_json_body()
136 142 if model is None:
137 143 raise web.HTTPError(400, u'JSON body missing')
138 144 model = yield gen.maybe_future(cm.update(model, path))
139 145 validate_model(model, expect_content=False)
140 146 self._finish_model(model)
141 147
142 148 @gen.coroutine
143 149 def _copy(self, copy_from, copy_to=None):
144 150 """Copy a file, optionally specifying a target directory."""
145 151 self.log.info(u"Copying {copy_from} to {copy_to}".format(
146 152 copy_from=copy_from,
147 153 copy_to=copy_to or '',
148 154 ))
149 155 model = yield gen.maybe_future(self.contents_manager.copy(copy_from, copy_to))
150 156 self.set_status(201)
151 157 validate_model(model, expect_content=False)
152 158 self._finish_model(model)
153 159
154 160 @gen.coroutine
155 161 def _upload(self, model, path):
156 162 """Handle upload of a new file to path"""
157 163 self.log.info(u"Uploading file to %s", path)
158 164 model = yield gen.maybe_future(self.contents_manager.new(model, path))
159 165 self.set_status(201)
160 166 validate_model(model, expect_content=False)
161 167 self._finish_model(model)
162 168
163 169 @gen.coroutine
164 170 def _new_untitled(self, path, type='', ext=''):
165 171 """Create a new, empty untitled entity"""
166 172 self.log.info(u"Creating new %s in %s", type or 'file', path)
167 173 model = yield gen.maybe_future(self.contents_manager.new_untitled(path=path, type=type, ext=ext))
168 174 self.set_status(201)
169 175 validate_model(model, expect_content=False)
170 176 self._finish_model(model)
171 177
172 178 @gen.coroutine
173 179 def _save(self, model, path):
174 180 """Save an existing file."""
175 181 self.log.info(u"Saving file at %s", path)
176 182 model = yield gen.maybe_future(self.contents_manager.save(model, path))
177 183 validate_model(model, expect_content=False)
178 184 self._finish_model(model)
179 185
180 186 @web.authenticated
181 187 @json_errors
182 188 @gen.coroutine
183 189 def post(self, path=''):
184 190 """Create a new file in the specified path.
185 191
186 192 POST creates new files. The server always decides on the name.
187 193
188 194 POST /api/contents/path
189 195 New untitled, empty file or directory.
190 196 POST /api/contents/path
191 197 with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
192 198 New copy of OtherNotebook in path
193 199 """
194 200
195 201 cm = self.contents_manager
196 202
197 203 if cm.file_exists(path):
198 204 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
199 205
200 206 if not cm.dir_exists(path):
201 207 raise web.HTTPError(404, "No such directory: %s" % path)
202 208
203 209 model = self.get_json_body()
204 210
205 211 if model is not None:
206 212 copy_from = model.get('copy_from')
207 213 ext = model.get('ext', '')
208 214 type = model.get('type', '')
209 215 if copy_from:
210 216 yield self._copy(copy_from, path)
211 217 else:
212 218 yield self._new_untitled(path, type=type, ext=ext)
213 219 else:
214 220 yield self._new_untitled(path)
215 221
216 222 @web.authenticated
217 223 @json_errors
218 224 @gen.coroutine
219 225 def put(self, path=''):
220 226 """Saves the file in the location specified by name and path.
221 227
222 228 PUT is very similar to POST, but the requester specifies the name,
223 229 whereas with POST, the server picks the name.
224 230
225 231 PUT /api/contents/path/Name.ipynb
226 232 Save notebook at ``path/Name.ipynb``. Notebook structure is specified
227 233 in `content` key of JSON request body. If content is not specified,
228 234 create a new empty notebook.
229 235 """
230 236 model = self.get_json_body()
231 237 if model:
232 238 if model.get('copy_from'):
233 239 raise web.HTTPError(400, "Cannot copy with PUT, only POST")
234 240 exists = yield gen.maybe_future(self.contents_manager.file_exists(path))
235 241 if exists:
236 242 yield gen.maybe_future(self._save(model, path))
237 243 else:
238 244 yield gen.maybe_future(self._upload(model, path))
239 245 else:
240 246 yield gen.maybe_future(self._new_untitled(path))
241 247
242 248 @web.authenticated
243 249 @json_errors
244 250 @gen.coroutine
245 251 def delete(self, path=''):
246 252 """delete a file in the given path"""
247 253 cm = self.contents_manager
248 254 self.log.warn('delete %s', path)
249 255 yield gen.maybe_future(cm.delete(path))
250 256 self.set_status(204)
251 257 self.finish()
252 258
253 259
254 260 class CheckpointsHandler(IPythonHandler):
255 261
256 262 SUPPORTED_METHODS = ('GET', 'POST')
257 263
258 264 @web.authenticated
259 265 @json_errors
260 266 @gen.coroutine
261 267 def get(self, path=''):
262 268 """get lists checkpoints for a file"""
263 269 cm = self.contents_manager
264 270 checkpoints = yield gen.maybe_future(cm.list_checkpoints(path))
265 271 data = json.dumps(checkpoints, default=date_default)
266 272 self.finish(data)
267 273
268 274 @web.authenticated
269 275 @json_errors
270 276 @gen.coroutine
271 277 def post(self, path=''):
272 278 """post creates a new checkpoint"""
273 279 cm = self.contents_manager
274 280 checkpoint = yield gen.maybe_future(cm.create_checkpoint(path))
275 281 data = json.dumps(checkpoint, default=date_default)
276 282 location = url_path_join(self.base_url, 'api/contents',
277 283 path, 'checkpoints', checkpoint['id'])
278 284 self.set_header('Location', url_escape(location))
279 285 self.set_status(201)
280 286 self.finish(data)
281 287
282 288
283 289 class ModifyCheckpointsHandler(IPythonHandler):
284 290
285 291 SUPPORTED_METHODS = ('POST', 'DELETE')
286 292
287 293 @web.authenticated
288 294 @json_errors
289 295 @gen.coroutine
290 296 def post(self, path, checkpoint_id):
291 297 """post restores a file from a checkpoint"""
292 298 cm = self.contents_manager
293 299 yield gen.maybe_future(cm.restore_checkpoint(checkpoint_id, path))
294 300 self.set_status(204)
295 301 self.finish()
296 302
297 303 @web.authenticated
298 304 @json_errors
299 305 @gen.coroutine
300 306 def delete(self, path, checkpoint_id):
301 307 """delete clears a checkpoint for a given file"""
302 308 cm = self.contents_manager
303 309 yield gen.maybe_future(cm.delete_checkpoint(checkpoint_id, path))
304 310 self.set_status(204)
305 311 self.finish()
306 312
307 313
308 314 class NotebooksRedirectHandler(IPythonHandler):
309 315 """Redirect /api/notebooks to /api/contents"""
310 316 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
311 317
312 318 def get(self, path):
313 319 self.log.warn("/api/notebooks is deprecated, use /api/contents")
314 320 self.redirect(url_path_join(
315 321 self.base_url,
316 322 'api/contents',
317 323 path
318 324 ))
319 325
320 326 put = patch = post = delete = get
321 327
322 328
323 329 #-----------------------------------------------------------------------------
324 330 # URL to handler mappings
325 331 #-----------------------------------------------------------------------------
326 332
327 333
328 334 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
329 335
330 336 default_handlers = [
331 337 (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
332 338 (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
333 339 ModifyCheckpointsHandler),
334 340 (r"/api/contents%s" % path_regex, ContentsHandler),
335 341 (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
336 342 ]
@@ -1,636 +1,655 b''
1 1 # coding: utf-8
2 2 """Test the contents webservice API."""
3 3
4 4 import base64
5 5 from contextlib import contextmanager
6 6 import io
7 7 import json
8 8 import os
9 9 import shutil
10 10 from unicodedata import normalize
11 11
12 12 pjoin = os.path.join
13 13
14 14 import requests
15 15
16 16 from ..filecheckpoints import GenericFileCheckpoints
17 17
18 18 from IPython.config import Config
19 19 from IPython.html.utils import url_path_join, url_escape, to_os_path
20 20 from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error
21 21 from IPython.nbformat import read, write, from_dict
22 22 from IPython.nbformat.v4 import (
23 23 new_notebook, new_markdown_cell,
24 24 )
25 25 from IPython.nbformat import v2
26 26 from IPython.utils import py3compat
27 27 from IPython.utils.data import uniq_stable
28 28 from IPython.utils.tempdir import TemporaryDirectory
29 29
30 30
31 31 def notebooks_only(dir_model):
32 32 return [nb for nb in dir_model['content'] if nb['type']=='notebook']
33 33
34 34 def dirs_only(dir_model):
35 35 return [x for x in dir_model['content'] if x['type']=='directory']
36 36
37 37
38 38 class API(object):
39 39 """Wrapper for contents API calls."""
40 40 def __init__(self, base_url):
41 41 self.base_url = base_url
42 42
43 43 def _req(self, verb, path, body=None, params=None):
44 44 response = requests.request(verb,
45 45 url_path_join(self.base_url, 'api/contents', path),
46 46 data=body, params=params,
47 47 )
48 48 response.raise_for_status()
49 49 return response
50 50
51 51 def list(self, path='/'):
52 52 return self._req('GET', path)
53 53
54 def read(self, path, type=None, format=None):
54 def read(self, path, type=None, format=None, content=None):
55 55 params = {}
56 56 if type is not None:
57 57 params['type'] = type
58 58 if format is not None:
59 59 params['format'] = format
60 if content == False:
61 params['content'] = '0'
60 62 return self._req('GET', path, params=params)
61 63
62 64 def create_untitled(self, path='/', ext='.ipynb'):
63 65 body = None
64 66 if ext:
65 67 body = json.dumps({'ext': ext})
66 68 return self._req('POST', path, body)
67 69
68 70 def mkdir_untitled(self, path='/'):
69 71 return self._req('POST', path, json.dumps({'type': 'directory'}))
70 72
71 73 def copy(self, copy_from, path='/'):
72 74 body = json.dumps({'copy_from':copy_from})
73 75 return self._req('POST', path, body)
74 76
75 77 def create(self, path='/'):
76 78 return self._req('PUT', path)
77 79
78 80 def upload(self, path, body):
79 81 return self._req('PUT', path, body)
80 82
81 83 def mkdir(self, path='/'):
82 84 return self._req('PUT', path, json.dumps({'type': 'directory'}))
83 85
84 86 def copy_put(self, copy_from, path='/'):
85 87 body = json.dumps({'copy_from':copy_from})
86 88 return self._req('PUT', path, body)
87 89
88 90 def save(self, path, body):
89 91 return self._req('PUT', path, body)
90 92
91 93 def delete(self, path='/'):
92 94 return self._req('DELETE', path)
93 95
94 96 def rename(self, path, new_path):
95 97 body = json.dumps({'path': new_path})
96 98 return self._req('PATCH', path, body)
97 99
98 100 def get_checkpoints(self, path):
99 101 return self._req('GET', url_path_join(path, 'checkpoints'))
100 102
101 103 def new_checkpoint(self, path):
102 104 return self._req('POST', url_path_join(path, 'checkpoints'))
103 105
104 106 def restore_checkpoint(self, path, checkpoint_id):
105 107 return self._req('POST', url_path_join(path, 'checkpoints', checkpoint_id))
106 108
107 109 def delete_checkpoint(self, path, checkpoint_id):
108 110 return self._req('DELETE', url_path_join(path, 'checkpoints', checkpoint_id))
109 111
110 112 class APITest(NotebookTestBase):
111 113 """Test the kernels web service API"""
112 114 dirs_nbs = [('', 'inroot'),
113 115 ('Directory with spaces in', 'inspace'),
114 116 (u'unicodΓ©', 'innonascii'),
115 117 ('foo', 'a'),
116 118 ('foo', 'b'),
117 119 ('foo', 'name with spaces'),
118 120 ('foo', u'unicodΓ©'),
119 121 ('foo/bar', 'baz'),
120 122 ('ordering', 'A'),
121 123 ('ordering', 'b'),
122 124 ('ordering', 'C'),
123 125 (u'Γ₯ b', u'Γ§ d'),
124 126 ]
125 127 hidden_dirs = ['.hidden', '__pycache__']
126 128
127 129 # Don't include root dir.
128 130 dirs = uniq_stable([py3compat.cast_unicode(d) for (d,n) in dirs_nbs[1:]])
129 131 top_level_dirs = {normalize('NFC', d.split('/')[0]) for d in dirs}
130 132
131 133 @staticmethod
132 134 def _blob_for_name(name):
133 135 return name.encode('utf-8') + b'\xFF'
134 136
135 137 @staticmethod
136 138 def _txt_for_name(name):
137 139 return u'%s text file' % name
138 140
139 141 def to_os_path(self, api_path):
140 142 return to_os_path(api_path, root=self.notebook_dir.name)
141 143
142 144 def make_dir(self, api_path):
143 145 """Create a directory at api_path"""
144 146 os_path = self.to_os_path(api_path)
145 147 try:
146 148 os.makedirs(os_path)
147 149 except OSError:
148 150 print("Directory already exists: %r" % os_path)
149 151
150 152 def make_txt(self, api_path, txt):
151 153 """Make a text file at a given api_path"""
152 154 os_path = self.to_os_path(api_path)
153 155 with io.open(os_path, 'w', encoding='utf-8') as f:
154 156 f.write(txt)
155 157
156 158 def make_blob(self, api_path, blob):
157 159 """Make a binary file at a given api_path"""
158 160 os_path = self.to_os_path(api_path)
159 161 with io.open(os_path, 'wb') as f:
160 162 f.write(blob)
161 163
162 164 def make_nb(self, api_path, nb):
163 165 """Make a notebook file at a given api_path"""
164 166 os_path = self.to_os_path(api_path)
165 167
166 168 with io.open(os_path, 'w', encoding='utf-8') as f:
167 169 write(nb, f, version=4)
168 170
169 171 def delete_dir(self, api_path):
170 172 """Delete a directory at api_path, removing any contents."""
171 173 os_path = self.to_os_path(api_path)
172 174 shutil.rmtree(os_path, ignore_errors=True)
173 175
174 176 def delete_file(self, api_path):
175 177 """Delete a file at the given path if it exists."""
176 178 if self.isfile(api_path):
177 179 os.unlink(self.to_os_path(api_path))
178 180
179 181 def isfile(self, api_path):
180 182 return os.path.isfile(self.to_os_path(api_path))
181 183
182 184 def isdir(self, api_path):
183 185 return os.path.isdir(self.to_os_path(api_path))
184 186
185 187 def setUp(self):
186 188
187 189 for d in (self.dirs + self.hidden_dirs):
188 190 self.make_dir(d)
189 191
190 192 for d, name in self.dirs_nbs:
191 193 # create a notebook
192 194 nb = new_notebook()
193 195 self.make_nb(u'{}/{}.ipynb'.format(d, name), nb)
194 196
195 197 # create a text file
196 198 txt = self._txt_for_name(name)
197 199 self.make_txt(u'{}/{}.txt'.format(d, name), txt)
198 200
199 201 # create a binary file
200 202 blob = self._blob_for_name(name)
201 203 self.make_blob(u'{}/{}.blob'.format(d, name), blob)
202 204
203 205 self.api = API(self.base_url())
204 206
205 207 def tearDown(self):
206 208 for dname in (list(self.top_level_dirs) + self.hidden_dirs):
207 209 self.delete_dir(dname)
208 210 self.delete_file('inroot.ipynb')
209 211
210 212 def test_list_notebooks(self):
211 213 nbs = notebooks_only(self.api.list().json())
212 214 self.assertEqual(len(nbs), 1)
213 215 self.assertEqual(nbs[0]['name'], 'inroot.ipynb')
214 216
215 217 nbs = notebooks_only(self.api.list('/Directory with spaces in/').json())
216 218 self.assertEqual(len(nbs), 1)
217 219 self.assertEqual(nbs[0]['name'], 'inspace.ipynb')
218 220
219 221 nbs = notebooks_only(self.api.list(u'/unicodΓ©/').json())
220 222 self.assertEqual(len(nbs), 1)
221 223 self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
222 224 self.assertEqual(nbs[0]['path'], u'unicodΓ©/innonascii.ipynb')
223 225
224 226 nbs = notebooks_only(self.api.list('/foo/bar/').json())
225 227 self.assertEqual(len(nbs), 1)
226 228 self.assertEqual(nbs[0]['name'], 'baz.ipynb')
227 229 self.assertEqual(nbs[0]['path'], 'foo/bar/baz.ipynb')
228 230
229 231 nbs = notebooks_only(self.api.list('foo').json())
230 232 self.assertEqual(len(nbs), 4)
231 233 nbnames = { normalize('NFC', n['name']) for n in nbs }
232 234 expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodΓ©.ipynb']
233 235 expected = { normalize('NFC', name) for name in expected }
234 236 self.assertEqual(nbnames, expected)
235 237
236 238 nbs = notebooks_only(self.api.list('ordering').json())
237 239 nbnames = [n['name'] for n in nbs]
238 240 expected = ['A.ipynb', 'b.ipynb', 'C.ipynb']
239 241 self.assertEqual(nbnames, expected)
240 242
241 243 def test_list_dirs(self):
242 244 dirs = dirs_only(self.api.list().json())
243 245 dir_names = {normalize('NFC', d['name']) for d in dirs}
244 246 self.assertEqual(dir_names, self.top_level_dirs) # Excluding hidden dirs
245 247
248 def test_get_dir_no_content(self):
249 for d in self.dirs:
250 model = self.api.read(d, content=False).json()
251 self.assertEqual(model['path'], d)
252 self.assertEqual(model['type'], 'directory')
253 self.assertIn('content', model)
254 self.assertEqual(model['content'], None)
255
246 256 def test_list_nonexistant_dir(self):
247 257 with assert_http_error(404):
248 258 self.api.list('nonexistant')
249 259
250 260 def test_get_nb_contents(self):
251 261 for d, name in self.dirs_nbs:
252 262 path = url_path_join(d, name + '.ipynb')
253 263 nb = self.api.read(path).json()
254 264 self.assertEqual(nb['name'], u'%s.ipynb' % name)
255 265 self.assertEqual(nb['path'], path)
256 266 self.assertEqual(nb['type'], 'notebook')
257 267 self.assertIn('content', nb)
258 268 self.assertEqual(nb['format'], 'json')
259 self.assertIn('content', nb)
260 269 self.assertIn('metadata', nb['content'])
261 270 self.assertIsInstance(nb['content']['metadata'], dict)
262 271
272 def test_get_nb_no_content(self):
273 for d, name in self.dirs_nbs:
274 path = url_path_join(d, name + '.ipynb')
275 nb = self.api.read(path, content=False).json()
276 self.assertEqual(nb['name'], u'%s.ipynb' % name)
277 self.assertEqual(nb['path'], path)
278 self.assertEqual(nb['type'], 'notebook')
279 self.assertIn('content', nb)
280 self.assertEqual(nb['content'], None)
281
263 282 def test_get_contents_no_such_file(self):
264 283 # Name that doesn't exist - should be a 404
265 284 with assert_http_error(404):
266 285 self.api.read('foo/q.ipynb')
267 286
268 287 def test_get_text_file_contents(self):
269 288 for d, name in self.dirs_nbs:
270 289 path = url_path_join(d, name + '.txt')
271 290 model = self.api.read(path).json()
272 291 self.assertEqual(model['name'], u'%s.txt' % name)
273 292 self.assertEqual(model['path'], path)
274 293 self.assertIn('content', model)
275 294 self.assertEqual(model['format'], 'text')
276 295 self.assertEqual(model['type'], 'file')
277 296 self.assertEqual(model['content'], self._txt_for_name(name))
278 297
279 298 # Name that doesn't exist - should be a 404
280 299 with assert_http_error(404):
281 300 self.api.read('foo/q.txt')
282 301
283 302 # Specifying format=text should fail on a non-UTF-8 file
284 303 with assert_http_error(400):
285 304 self.api.read('foo/bar/baz.blob', type='file', format='text')
286 305
287 306 def test_get_binary_file_contents(self):
288 307 for d, name in self.dirs_nbs:
289 308 path = url_path_join(d, name + '.blob')
290 309 model = self.api.read(path).json()
291 310 self.assertEqual(model['name'], u'%s.blob' % name)
292 311 self.assertEqual(model['path'], path)
293 312 self.assertIn('content', model)
294 313 self.assertEqual(model['format'], 'base64')
295 314 self.assertEqual(model['type'], 'file')
296 315 self.assertEqual(
297 316 base64.decodestring(model['content'].encode('ascii')),
298 317 self._blob_for_name(name),
299 318 )
300 319
301 320 # Name that doesn't exist - should be a 404
302 321 with assert_http_error(404):
303 322 self.api.read('foo/q.txt')
304 323
305 324 def test_get_bad_type(self):
306 325 with assert_http_error(400):
307 326 self.api.read(u'unicodΓ©', type='file') # this is a directory
308 327
309 328 with assert_http_error(400):
310 329 self.api.read(u'unicodΓ©/innonascii.ipynb', type='directory')
311 330
312 331 def _check_created(self, resp, path, type='notebook'):
313 332 self.assertEqual(resp.status_code, 201)
314 333 location_header = py3compat.str_to_unicode(resp.headers['Location'])
315 334 self.assertEqual(location_header, url_escape(url_path_join(u'/api/contents', path)))
316 335 rjson = resp.json()
317 336 self.assertEqual(rjson['name'], path.rsplit('/', 1)[-1])
318 337 self.assertEqual(rjson['path'], path)
319 338 self.assertEqual(rjson['type'], type)
320 339 isright = self.isdir if type == 'directory' else self.isfile
321 340 assert isright(path)
322 341
323 342 def test_create_untitled(self):
324 343 resp = self.api.create_untitled(path=u'Γ₯ b')
325 344 self._check_created(resp, u'Γ₯ b/Untitled.ipynb')
326 345
327 346 # Second time
328 347 resp = self.api.create_untitled(path=u'Γ₯ b')
329 348 self._check_created(resp, u'Γ₯ b/Untitled1.ipynb')
330 349
331 350 # And two directories down
332 351 resp = self.api.create_untitled(path='foo/bar')
333 352 self._check_created(resp, 'foo/bar/Untitled.ipynb')
334 353
335 354 def test_create_untitled_txt(self):
336 355 resp = self.api.create_untitled(path='foo/bar', ext='.txt')
337 356 self._check_created(resp, 'foo/bar/untitled.txt', type='file')
338 357
339 358 resp = self.api.read(path='foo/bar/untitled.txt')
340 359 model = resp.json()
341 360 self.assertEqual(model['type'], 'file')
342 361 self.assertEqual(model['format'], 'text')
343 362 self.assertEqual(model['content'], '')
344 363
345 364 def test_upload(self):
346 365 nb = new_notebook()
347 366 nbmodel = {'content': nb, 'type': 'notebook'}
348 367 path = u'Γ₯ b/Upload tΓ©st.ipynb'
349 368 resp = self.api.upload(path, body=json.dumps(nbmodel))
350 369 self._check_created(resp, path)
351 370
352 371 def test_mkdir_untitled(self):
353 372 resp = self.api.mkdir_untitled(path=u'Γ₯ b')
354 373 self._check_created(resp, u'Γ₯ b/Untitled Folder', type='directory')
355 374
356 375 # Second time
357 376 resp = self.api.mkdir_untitled(path=u'Γ₯ b')
358 377 self._check_created(resp, u'Γ₯ b/Untitled Folder 1', type='directory')
359 378
360 379 # And two directories down
361 380 resp = self.api.mkdir_untitled(path='foo/bar')
362 381 self._check_created(resp, 'foo/bar/Untitled Folder', type='directory')
363 382
364 383 def test_mkdir(self):
365 384 path = u'Γ₯ b/New βˆ‚ir'
366 385 resp = self.api.mkdir(path)
367 386 self._check_created(resp, path, type='directory')
368 387
369 388 def test_mkdir_hidden_400(self):
370 389 with assert_http_error(400):
371 390 resp = self.api.mkdir(u'Γ₯ b/.hidden')
372 391
373 392 def test_upload_txt(self):
374 393 body = u'ΓΌnicode tΓ©xt'
375 394 model = {
376 395 'content' : body,
377 396 'format' : 'text',
378 397 'type' : 'file',
379 398 }
380 399 path = u'Γ₯ b/Upload tΓ©st.txt'
381 400 resp = self.api.upload(path, body=json.dumps(model))
382 401
383 402 # check roundtrip
384 403 resp = self.api.read(path)
385 404 model = resp.json()
386 405 self.assertEqual(model['type'], 'file')
387 406 self.assertEqual(model['format'], 'text')
388 407 self.assertEqual(model['content'], body)
389 408
390 409 def test_upload_b64(self):
391 410 body = b'\xFFblob'
392 411 b64body = base64.encodestring(body).decode('ascii')
393 412 model = {
394 413 'content' : b64body,
395 414 'format' : 'base64',
396 415 'type' : 'file',
397 416 }
398 417 path = u'Γ₯ b/Upload tΓ©st.blob'
399 418 resp = self.api.upload(path, body=json.dumps(model))
400 419
401 420 # check roundtrip
402 421 resp = self.api.read(path)
403 422 model = resp.json()
404 423 self.assertEqual(model['type'], 'file')
405 424 self.assertEqual(model['path'], path)
406 425 self.assertEqual(model['format'], 'base64')
407 426 decoded = base64.decodestring(model['content'].encode('ascii'))
408 427 self.assertEqual(decoded, body)
409 428
410 429 def test_upload_v2(self):
411 430 nb = v2.new_notebook()
412 431 ws = v2.new_worksheet()
413 432 nb.worksheets.append(ws)
414 433 ws.cells.append(v2.new_code_cell(input='print("hi")'))
415 434 nbmodel = {'content': nb, 'type': 'notebook'}
416 435 path = u'Γ₯ b/Upload tΓ©st.ipynb'
417 436 resp = self.api.upload(path, body=json.dumps(nbmodel))
418 437 self._check_created(resp, path)
419 438 resp = self.api.read(path)
420 439 data = resp.json()
421 440 self.assertEqual(data['content']['nbformat'], 4)
422 441
423 442 def test_copy(self):
424 443 resp = self.api.copy(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b')
425 444 self._check_created(resp, u'Γ₯ b/Γ§ d-Copy1.ipynb')
426 445
427 446 resp = self.api.copy(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b')
428 447 self._check_created(resp, u'Γ₯ b/Γ§ d-Copy2.ipynb')
429 448
430 449 def test_copy_copy(self):
431 450 resp = self.api.copy(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b')
432 451 self._check_created(resp, u'Γ₯ b/Γ§ d-Copy1.ipynb')
433 452
434 453 resp = self.api.copy(u'Γ₯ b/Γ§ d-Copy1.ipynb', u'Γ₯ b')
435 454 self._check_created(resp, u'Γ₯ b/Γ§ d-Copy2.ipynb')
436 455
437 456 def test_copy_path(self):
438 457 resp = self.api.copy(u'foo/a.ipynb', u'Γ₯ b')
439 458 self._check_created(resp, u'Γ₯ b/a.ipynb')
440 459
441 460 resp = self.api.copy(u'foo/a.ipynb', u'Γ₯ b')
442 461 self._check_created(resp, u'Γ₯ b/a-Copy1.ipynb')
443 462
444 463 def test_copy_put_400(self):
445 464 with assert_http_error(400):
446 465 resp = self.api.copy_put(u'Γ₯ b/Γ§ d.ipynb', u'Γ₯ b/cΓΈpy.ipynb')
447 466
448 467 def test_copy_dir_400(self):
449 468 # can't copy directories
450 469 with assert_http_error(400):
451 470 resp = self.api.copy(u'Γ₯ b', u'foo')
452 471
453 472 def test_delete(self):
454 473 for d, name in self.dirs_nbs:
455 474 print('%r, %r' % (d, name))
456 475 resp = self.api.delete(url_path_join(d, name + '.ipynb'))
457 476 self.assertEqual(resp.status_code, 204)
458 477
459 478 for d in self.dirs + ['/']:
460 479 nbs = notebooks_only(self.api.list(d).json())
461 480 print('------')
462 481 print(d)
463 482 print(nbs)
464 483 self.assertEqual(nbs, [])
465 484
466 485 def test_delete_dirs(self):
467 486 # depth-first delete everything, so we don't try to delete empty directories
468 487 for name in sorted(self.dirs + ['/'], key=len, reverse=True):
469 488 listing = self.api.list(name).json()['content']
470 489 for model in listing:
471 490 self.api.delete(model['path'])
472 491 listing = self.api.list('/').json()['content']
473 492 self.assertEqual(listing, [])
474 493
475 494 def test_delete_non_empty_dir(self):
476 495 """delete non-empty dir raises 400"""
477 496 with assert_http_error(400):
478 497 self.api.delete(u'Γ₯ b')
479 498
480 499 def test_rename(self):
481 500 resp = self.api.rename('foo/a.ipynb', 'foo/z.ipynb')
482 501 self.assertEqual(resp.headers['Location'].split('/')[-1], 'z.ipynb')
483 502 self.assertEqual(resp.json()['name'], 'z.ipynb')
484 503 self.assertEqual(resp.json()['path'], 'foo/z.ipynb')
485 504 assert self.isfile('foo/z.ipynb')
486 505
487 506 nbs = notebooks_only(self.api.list('foo').json())
488 507 nbnames = set(n['name'] for n in nbs)
489 508 self.assertIn('z.ipynb', nbnames)
490 509 self.assertNotIn('a.ipynb', nbnames)
491 510
492 511 def test_rename_existing(self):
493 512 with assert_http_error(409):
494 513 self.api.rename('foo/a.ipynb', 'foo/b.ipynb')
495 514
496 515 def test_save(self):
497 516 resp = self.api.read('foo/a.ipynb')
498 517 nbcontent = json.loads(resp.text)['content']
499 518 nb = from_dict(nbcontent)
500 519 nb.cells.append(new_markdown_cell(u'Created by test Β³'))
501 520
502 521 nbmodel= {'content': nb, 'type': 'notebook'}
503 522 resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
504 523
505 524 nbcontent = self.api.read('foo/a.ipynb').json()['content']
506 525 newnb = from_dict(nbcontent)
507 526 self.assertEqual(newnb.cells[0].source,
508 527 u'Created by test Β³')
509 528
510 529 def test_checkpoints(self):
511 530 resp = self.api.read('foo/a.ipynb')
512 531 r = self.api.new_checkpoint('foo/a.ipynb')
513 532 self.assertEqual(r.status_code, 201)
514 533 cp1 = r.json()
515 534 self.assertEqual(set(cp1), {'id', 'last_modified'})
516 535 self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id'])
517 536
518 537 # Modify it
519 538 nbcontent = json.loads(resp.text)['content']
520 539 nb = from_dict(nbcontent)
521 540 hcell = new_markdown_cell('Created by test')
522 541 nb.cells.append(hcell)
523 542 # Save
524 543 nbmodel= {'content': nb, 'type': 'notebook'}
525 544 resp = self.api.save('foo/a.ipynb', body=json.dumps(nbmodel))
526 545
527 546 # List checkpoints
528 547 cps = self.api.get_checkpoints('foo/a.ipynb').json()
529 548 self.assertEqual(cps, [cp1])
530 549
531 550 nbcontent = self.api.read('foo/a.ipynb').json()['content']
532 551 nb = from_dict(nbcontent)
533 552 self.assertEqual(nb.cells[0].source, 'Created by test')
534 553
535 554 # Restore cp1
536 555 r = self.api.restore_checkpoint('foo/a.ipynb', cp1['id'])
537 556 self.assertEqual(r.status_code, 204)
538 557 nbcontent = self.api.read('foo/a.ipynb').json()['content']
539 558 nb = from_dict(nbcontent)
540 559 self.assertEqual(nb.cells, [])
541 560
542 561 # Delete cp1
543 562 r = self.api.delete_checkpoint('foo/a.ipynb', cp1['id'])
544 563 self.assertEqual(r.status_code, 204)
545 564 cps = self.api.get_checkpoints('foo/a.ipynb').json()
546 565 self.assertEqual(cps, [])
547 566
548 567 def test_file_checkpoints(self):
549 568 """
550 569 Test checkpointing of non-notebook files.
551 570 """
552 571 filename = 'foo/a.txt'
553 572 resp = self.api.read(filename)
554 573 orig_content = json.loads(resp.text)['content']
555 574
556 575 # Create a checkpoint.
557 576 r = self.api.new_checkpoint(filename)
558 577 self.assertEqual(r.status_code, 201)
559 578 cp1 = r.json()
560 579 self.assertEqual(set(cp1), {'id', 'last_modified'})
561 580 self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id'])
562 581
563 582 # Modify the file and save.
564 583 new_content = orig_content + '\nsecond line'
565 584 model = {
566 585 'content': new_content,
567 586 'type': 'file',
568 587 'format': 'text',
569 588 }
570 589 resp = self.api.save(filename, body=json.dumps(model))
571 590
572 591 # List checkpoints
573 592 cps = self.api.get_checkpoints(filename).json()
574 593 self.assertEqual(cps, [cp1])
575 594
576 595 content = self.api.read(filename).json()['content']
577 596 self.assertEqual(content, new_content)
578 597
579 598 # Restore cp1
580 599 r = self.api.restore_checkpoint(filename, cp1['id'])
581 600 self.assertEqual(r.status_code, 204)
582 601 restored_content = self.api.read(filename).json()['content']
583 602 self.assertEqual(restored_content, orig_content)
584 603
585 604 # Delete cp1
586 605 r = self.api.delete_checkpoint(filename, cp1['id'])
587 606 self.assertEqual(r.status_code, 204)
588 607 cps = self.api.get_checkpoints(filename).json()
589 608 self.assertEqual(cps, [])
590 609
591 610 @contextmanager
592 611 def patch_cp_root(self, dirname):
593 612 """
594 613 Temporarily patch the root dir of our checkpoint manager.
595 614 """
596 615 cpm = self.notebook.contents_manager.checkpoints
597 616 old_dirname = cpm.root_dir
598 617 cpm.root_dir = dirname
599 618 try:
600 619 yield
601 620 finally:
602 621 cpm.root_dir = old_dirname
603 622
604 623 def test_checkpoints_separate_root(self):
605 624 """
606 625 Test that FileCheckpoints functions correctly even when it's
607 626 using a different root dir from FileContentsManager. This also keeps
608 627 the implementation honest for use with ContentsManagers that don't map
609 628 models to the filesystem
610 629
611 630 Override this method to a no-op when testing other managers.
612 631 """
613 632 with TemporaryDirectory() as td:
614 633 with self.patch_cp_root(td):
615 634 self.test_checkpoints()
616 635
617 636 with TemporaryDirectory() as td:
618 637 with self.patch_cp_root(td):
619 638 self.test_file_checkpoints()
620 639
621 640
622 641 class GenericFileCheckpointsAPITest(APITest):
623 642 """
624 643 Run the tests from APITest with GenericFileCheckpoints.
625 644 """
626 645 config = Config()
627 646 config.FileContentsManager.checkpoints_class = GenericFileCheckpoints
628 647
629 648 def test_config_did_something(self):
630 649
631 650 self.assertIsInstance(
632 651 self.notebook.contents_manager.checkpoints,
633 652 GenericFileCheckpoints,
634 653 )
635 654
636 655
@@ -1,249 +1,249 b''
1 1 // Copyright (c) IPython Development Team.
2 2 // Distributed under the terms of the Modified BSD License.
3 3
4 4 define(function(require) {
5 5 "use strict";
6 6
7 7 var $ = require('jquery');
8 8 var utils = require('base/js/utils');
9 9
10 10 var Contents = function(options) {
11 11 /**
12 12 * Constructor
13 13 *
14 14 * A contents handles passing file operations
15 15 * to the back-end. This includes checkpointing
16 16 * with the normal file operations.
17 17 *
18 18 * Parameters:
19 19 * options: dictionary
20 20 * Dictionary of keyword arguments.
21 21 * base_url: string
22 22 */
23 23 this.base_url = options.base_url;
24 24 };
25 25
26 26 /** Error type */
27 27 Contents.DIRECTORY_NOT_EMPTY_ERROR = 'DirectoryNotEmptyError';
28 28
29 29 Contents.DirectoryNotEmptyError = function() {
30 30 // Constructor
31 31 //
32 32 // An error representing the result of attempting to delete a non-empty
33 33 // directory.
34 34 this.message = 'A directory must be empty before being deleted.';
35 35 };
36 36
37 37 Contents.DirectoryNotEmptyError.prototype = Object.create(Error.prototype);
38 38 Contents.DirectoryNotEmptyError.prototype.name =
39 39 Contents.DIRECTORY_NOT_EMPTY_ERROR;
40 40
41 41
42 42 Contents.prototype.api_url = function() {
43 43 var url_parts = [this.base_url, 'api/contents'].concat(
44 44 Array.prototype.slice.apply(arguments));
45 45 return utils.url_join_encode.apply(null, url_parts);
46 46 };
47 47
48 48 /**
49 49 * Creates a basic error handler that wraps a jqXHR error as an Error.
50 50 *
51 51 * Takes a callback that accepts an Error, and returns a callback that can
52 52 * be passed directly to $.ajax, which will wrap the error from jQuery
53 53 * as an Error, and pass that to the original callback.
54 54 *
55 55 * @method create_basic_error_handler
56 56 * @param{Function} callback
57 57 * @return{Function}
58 58 */
59 59 Contents.prototype.create_basic_error_handler = function(callback) {
60 60 if (!callback) {
61 61 return utils.log_ajax_error;
62 62 }
63 63 return function(xhr, status, error) {
64 64 callback(utils.wrap_ajax_error(xhr, status, error));
65 65 };
66 66 };
67 67
68 68 /**
69 69 * File Functions (including notebook operations)
70 70 */
71 71
72 72 /**
73 73 * Get a file.
74 74 *
75 * Calls success with file JSON model, or error with error.
76 *
77 75 * @method get
78 76 * @param {String} path
79 77 * @param {Object} options
80 78 * type : 'notebook', 'file', or 'directory'
81 79 * format: 'text' or 'base64'; only relevant for type: 'file'
80 * content: true or false; // whether to include the content
82 81 */
83 82 Contents.prototype.get = function (path, options) {
84 83 /**
85 84 * We do the call with settings so we can set cache to false.
86 85 */
87 86 var settings = {
88 87 processData : false,
89 88 cache : false,
90 89 type : "GET",
91 90 dataType : "json",
92 91 };
93 92 var url = this.api_url(path);
94 93 var params = {};
95 94 if (options.type) { params.type = options.type; }
96 95 if (options.format) { params.format = options.format; }
96 if (options.content === false) { params.content = '0'; }
97 97 return utils.promising_ajax(url + '?' + $.param(params), settings);
98 98 };
99 99
100 100
101 101 /**
102 102 * Creates a new untitled file or directory in the specified directory path.
103 103 *
104 104 * @method new
105 105 * @param {String} path: the directory in which to create the new file/directory
106 106 * @param {Object} options:
107 107 * ext: file extension to use
108 108 * type: model type to create ('notebook', 'file', or 'directory')
109 109 */
110 110 Contents.prototype.new_untitled = function(path, options) {
111 111 var data = JSON.stringify({
112 112 ext: options.ext,
113 113 type: options.type
114 114 });
115 115
116 116 var settings = {
117 117 processData : false,
118 118 type : "POST",
119 119 data: data,
120 120 dataType : "json",
121 121 };
122 122 return utils.promising_ajax(this.api_url(path), settings);
123 123 };
124 124
125 125 Contents.prototype.delete = function(path) {
126 126 var settings = {
127 127 processData : false,
128 128 type : "DELETE",
129 129 dataType : "json",
130 130 };
131 131 var url = this.api_url(path);
132 132 return utils.promising_ajax(url, settings).catch(
133 133 // Translate certain errors to more specific ones.
134 134 function(error) {
135 135 // TODO: update IPEP27 to specify errors more precisely, so
136 136 // that error types can be detected here with certainty.
137 137 if (error.xhr.status === 400) {
138 138 throw new Contents.DirectoryNotEmptyError();
139 139 }
140 140 throw error;
141 141 }
142 142 );
143 143 };
144 144
145 145 Contents.prototype.rename = function(path, new_path) {
146 146 var data = {path: new_path};
147 147 var settings = {
148 148 processData : false,
149 149 type : "PATCH",
150 150 data : JSON.stringify(data),
151 151 dataType: "json",
152 152 contentType: 'application/json',
153 153 };
154 154 var url = this.api_url(path);
155 155 return utils.promising_ajax(url, settings);
156 156 };
157 157
158 158 Contents.prototype.save = function(path, model) {
159 159 /**
160 160 * We do the call with settings so we can set cache to false.
161 161 */
162 162 var settings = {
163 163 processData : false,
164 164 type : "PUT",
165 165 dataType: "json",
166 166 data : JSON.stringify(model),
167 167 contentType: 'application/json',
168 168 };
169 169 var url = this.api_url(path);
170 170 return utils.promising_ajax(url, settings);
171 171 };
172 172
173 173 Contents.prototype.copy = function(from_file, to_dir) {
174 174 /**
175 175 * Copy a file into a given directory via POST
176 176 * The server will select the name of the copied file
177 177 */
178 178 var url = this.api_url(to_dir);
179 179
180 180 var settings = {
181 181 processData : false,
182 182 type: "POST",
183 183 data: JSON.stringify({copy_from: from_file}),
184 184 dataType : "json",
185 185 };
186 186 return utils.promising_ajax(url, settings);
187 187 };
188 188
189 189 /**
190 190 * Checkpointing Functions
191 191 */
192 192
193 193 Contents.prototype.create_checkpoint = function(path) {
194 194 var url = this.api_url(path, 'checkpoints');
195 195 var settings = {
196 196 type : "POST",
197 197 dataType : "json",
198 198 };
199 199 return utils.promising_ajax(url, settings);
200 200 };
201 201
202 202 Contents.prototype.list_checkpoints = function(path) {
203 203 var url = this.api_url(path, 'checkpoints');
204 204 var settings = {
205 205 type : "GET",
206 206 cache: false,
207 207 dataType: "json",
208 208 };
209 209 return utils.promising_ajax(url, settings);
210 210 };
211 211
212 212 Contents.prototype.restore_checkpoint = function(path, checkpoint_id) {
213 213 var url = this.api_url(path, 'checkpoints', checkpoint_id);
214 214 var settings = {
215 215 type : "POST",
216 216 };
217 217 return utils.promising_ajax(url, settings);
218 218 };
219 219
220 220 Contents.prototype.delete_checkpoint = function(path, checkpoint_id) {
221 221 var url = this.api_url(path, 'checkpoints', checkpoint_id);
222 222 var settings = {
223 223 type : "DELETE",
224 224 };
225 225 return utils.promising_ajax(url, settings);
226 226 };
227 227
228 228 /**
229 229 * File management functions
230 230 */
231 231
232 232 /**
233 233 * List notebooks and directories at a given path
234 234 *
235 235 * On success, load_callback is called with an array of dictionaries
236 236 * representing individual files or directories. Each dictionary has
237 237 * the keys:
238 238 * type: "notebook" or "directory"
239 239 * created: created date
240 240 * last_modified: last modified dat
241 241 * @method list_notebooks
242 242 * @param {String} path The path to list notebooks in
243 243 */
244 244 Contents.prototype.list_contents = function(path) {
245 245 return this.get(path, {type: 'directory'});
246 246 };
247 247
248 248 return {'Contents': Contents};
249 249 });
General Comments 0
You need to be logged in to leave comments. Login now