##// END OF EJS Templates
DEV: Add full support for non-notebook checkpoints.
Scott Sanderson -
Show More
@@ -60,7 +60,10 b' class FileManagerMixin(object):'
60 """
60 """
61 Mixin for ContentsAPI classes that interact with the filesystem.
61 Mixin for ContentsAPI classes that interact with the filesystem.
62
62
63 Shared by both FileContentsManager and FileCheckpointManager.
63 Provides facilities for reading, writing, and copying both notebooks and
64 generic files.
65
66 Shared by FileContentsManager and FileCheckpointManager.
64
67
65 Note
68 Note
66 ----
69 ----
@@ -114,17 +117,6 b' class FileManagerMixin(object):'
114 except OSError:
117 except OSError:
115 self.log.debug("copystat on %s failed", dest, exc_info=True)
118 self.log.debug("copystat on %s failed", dest, exc_info=True)
116
119
117 def _read_notebook(self, os_path, as_version=4):
118 """Read a notebook from an os path."""
119 with self.open(os_path, 'r', encoding='utf-8') as f:
120 try:
121 return nbformat.read(f, as_version=as_version)
122 except Exception as e:
123 raise web.HTTPError(
124 400,
125 u"Unreadable Notebook: %s %r" % (os_path, e),
126 )
127
128 def _get_os_path(self, path):
120 def _get_os_path(self, path):
129 """Given an API path, return its file system path.
121 """Given an API path, return its file system path.
130
122
@@ -140,6 +132,70 b' class FileManagerMixin(object):'
140 """
132 """
141 return to_os_path(path, self.root_dir)
133 return to_os_path(path, self.root_dir)
142
134
135 def _read_notebook(self, os_path, as_version=4):
136 """Read a notebook from an os path."""
137 with self.open(os_path, 'r', encoding='utf-8') as f:
138 try:
139 return nbformat.read(f, as_version=as_version)
140 except Exception as e:
141 raise web.HTTPError(
142 400,
143 u"Unreadable Notebook: %s %r" % (os_path, e),
144 )
145
146 def _save_notebook(self, os_path, nb):
147 """Save a notebook to an os_path."""
148 with self.atomic_writing(os_path, encoding='utf-8') as f:
149 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
150
151 def _read_file(self, os_path, format):
152 """Read a non-notebook file.
153
154 os_path: The path to be read.
155 format:
156 If 'text', the contents will be decoded as UTF-8.
157 If 'base64', the raw bytes contents will be encoded as base64.
158 If not specified, try to decode as UTF-8, and fall back to base64
159 """
160 if not os.path.isfile(os_path):
161 raise web.HTTPError(400, "Cannot read non-file %s" % os_path)
162
163 with self.open(os_path, 'rb') as f:
164 bcontent = f.read()
165
166 if format is None or format == 'text':
167 # Try to interpret as unicode if format is unknown or if unicode
168 # was explicitly requested.
169 try:
170 return bcontent.decode('utf8'), 'text'
171 except UnicodeError as e:
172 if format == 'text':
173 raise web.HTTPError(
174 400,
175 "%s is not UTF-8 encoded" % os_path,
176 reason='bad format',
177 )
178 return base64.encodestring(bcontent).decode('ascii'), 'base64'
179
180 def _save_file(self, os_path, content, format):
181 """Save content of a generic file."""
182 if format not in {'text', 'base64'}:
183 raise web.HTTPError(
184 400,
185 "Must specify format of file contents as 'text' or 'base64'",
186 )
187 try:
188 if format == 'text':
189 bcontent = content.encode('utf8')
190 else:
191 b64_bytes = content.encode('ascii')
192 bcontent = base64.decodestring(b64_bytes)
193 except Exception as e:
194 raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e))
195
196 with self.atomic_writing(os_path, text=False) as f:
197 f.write(bcontent)
198
143
199
144 class FileCheckpointManager(FileManagerMixin, CheckpointManager):
200 class FileCheckpointManager(FileManagerMixin, CheckpointManager):
145 """
201 """
@@ -167,39 +223,51 b' class FileCheckpointManager(FileManagerMixin, CheckpointManager):'
167 return getcwd()
223 return getcwd()
168
224
169 # public checkpoint API
225 # public checkpoint API
170 def create_checkpoint(self, nb, path):
226 def create_file_checkpoint(self, content, format, path):
227 """Create a checkpoint from the current content of a notebook."""
228 path = path.strip('/')
229 # only the one checkpoint ID:
230 checkpoint_id = u"checkpoint"
231 os_checkpoint_path = self.checkpoint_path(checkpoint_id, path)
232 self.log.debug("creating checkpoint for %s", path)
233 with self.perm_to_403():
234 self._save_file(os_checkpoint_path, content, format=format)
235
236 # return the checkpoint info
237 return self.checkpoint_model(checkpoint_id, os_checkpoint_path)
238
239 def create_notebook_checkpoint(self, nb, path):
171 """Create a checkpoint from the current content of a notebook."""
240 """Create a checkpoint from the current content of a notebook."""
172 path = path.strip('/')
241 path = path.strip('/')
173 # only the one checkpoint ID:
242 # only the one checkpoint ID:
174 checkpoint_id = u"checkpoint"
243 checkpoint_id = u"checkpoint"
175 os_checkpoint_path = self.get_checkpoint_path(checkpoint_id, path)
244 os_checkpoint_path = self.checkpoint_path(checkpoint_id, path)
176 self.log.debug("creating checkpoint for %s", path)
245 self.log.debug("creating checkpoint for %s", path)
177 with self.perm_to_403():
246 with self.perm_to_403():
178 self._save_notebook(os_checkpoint_path, nb)
247 self._save_notebook(os_checkpoint_path, nb)
179
248
180 # return the checkpoint info
249 # return the checkpoint info
181 return self.get_checkpoint_model(checkpoint_id, path)
250 return self.checkpoint_model(checkpoint_id, os_checkpoint_path)
182
251
183 def get_checkpoint_content(self, checkpoint_id, path):
252 def get_checkpoint(self, checkpoint_id, path, type):
184 """Get the content of a checkpoint.
253 """Get the content of a checkpoint.
185
254
186 Returns an unvalidated model with the same structure as
255 Returns a pair of (content, type).
187 the return value of ContentsManager.get
188 """
256 """
189 path = path.strip('/')
257 path = path.strip('/')
190 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
258 self.log.info("restoring %s from checkpoint %s", path, checkpoint_id)
191 os_checkpoint_path = self.get_checkpoint_path(checkpoint_id, path)
259 os_checkpoint_path = self.checkpoint_path(checkpoint_id, path)
192 return self._read_notebook(os_checkpoint_path, as_version=4)
260 if not os.path.isfile(os_checkpoint_path):
193
261 self.no_such_checkpoint(path, checkpoint_id)
194 def _save_notebook(self, os_path, nb):
262 if type == 'notebook':
195 """Save a notebook file."""
263 return self._read_notebook(os_checkpoint_path, as_version=4), None
196 with self.atomic_writing(os_path, encoding='utf-8') as f:
264 else:
197 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
265 return self._read_file(os_checkpoint_path, format=None)
198
266
199 def rename_checkpoint(self, checkpoint_id, old_path, new_path):
267 def rename_checkpoint(self, checkpoint_id, old_path, new_path):
200 """Rename a checkpoint from old_path to new_path."""
268 """Rename a checkpoint from old_path to new_path."""
201 old_cp_path = self.get_checkpoint_path(checkpoint_id, old_path)
269 old_cp_path = self.checkpoint_path(checkpoint_id, old_path)
202 new_cp_path = self.get_checkpoint_path(checkpoint_id, new_path)
270 new_cp_path = self.checkpoint_path(checkpoint_id, new_path)
203 if os.path.isfile(old_cp_path):
271 if os.path.isfile(old_cp_path):
204 self.log.debug(
272 self.log.debug(
205 "Renaming checkpoint %s -> %s",
273 "Renaming checkpoint %s -> %s",
@@ -212,7 +280,7 b' class FileCheckpointManager(FileManagerMixin, CheckpointManager):'
212 def delete_checkpoint(self, checkpoint_id, path):
280 def delete_checkpoint(self, checkpoint_id, path):
213 """delete a file's checkpoint"""
281 """delete a file's checkpoint"""
214 path = path.strip('/')
282 path = path.strip('/')
215 cp_path = self.get_checkpoint_path(checkpoint_id, path)
283 cp_path = self.checkpoint_path(checkpoint_id, path)
216 if not os.path.isfile(cp_path):
284 if not os.path.isfile(cp_path):
217 self.no_such_checkpoint(path, checkpoint_id)
285 self.no_such_checkpoint(path, checkpoint_id)
218
286
@@ -227,14 +295,14 b' class FileCheckpointManager(FileManagerMixin, CheckpointManager):'
227 """
295 """
228 path = path.strip('/')
296 path = path.strip('/')
229 checkpoint_id = "checkpoint"
297 checkpoint_id = "checkpoint"
230 os_path = self.get_checkpoint_path(checkpoint_id, path)
298 os_path = self.checkpoint_path(checkpoint_id, path)
231 if not os.path.exists(os_path):
299 if not os.path.isfile(os_path):
232 return []
300 return []
233 else:
301 else:
234 return [self.get_checkpoint_model(checkpoint_id, path)]
302 return [self.checkpoint_model(checkpoint_id, os_path)]
235
303
236 # Checkpoint-related utilities
304 # Checkpoint-related utilities
237 def get_checkpoint_path(self, checkpoint_id, path):
305 def checkpoint_path(self, checkpoint_id, path):
238 """find the path to a checkpoint"""
306 """find the path to a checkpoint"""
239 path = path.strip('/')
307 path = path.strip('/')
240 parent, name = ('/' + path).rsplit('/', 1)
308 parent, name = ('/' + path).rsplit('/', 1)
@@ -252,11 +320,9 b' class FileCheckpointManager(FileManagerMixin, CheckpointManager):'
252 cp_path = os.path.join(cp_dir, filename)
320 cp_path = os.path.join(cp_dir, filename)
253 return cp_path
321 return cp_path
254
322
255 def get_checkpoint_model(self, checkpoint_id, path):
323 def checkpoint_model(self, checkpoint_id, os_path):
256 """construct the info dict for a given checkpoint"""
324 """construct the info dict for a given checkpoint"""
257 path = path.strip('/')
325 stats = os.stat(os_path)
258 cp_path = self.get_checkpoint_path(checkpoint_id, path)
259 stats = os.stat(cp_path)
260 last_modified = tz.utcfromtimestamp(stats.st_mtime)
326 last_modified = tz.utcfromtimestamp(stats.st_mtime)
261 info = dict(
327 info = dict(
262 id=checkpoint_id,
328 id=checkpoint_id,
@@ -499,29 +565,17 b' class FileContentsManager(FileManagerMixin, ContentsManager):'
499 os_path = self._get_os_path(path)
565 os_path = self._get_os_path(path)
500
566
501 if content:
567 if content:
502 if not os.path.isfile(os_path):
568 content, format = self._read_file(os_path, format)
503 # could be FIFO
569 default_mime = {
504 raise web.HTTPError(400, "Cannot get content of non-file %s" % os_path)
570 'text': 'text/plain',
505 with self.open(os_path, 'rb') as f:
571 'base64': 'application/octet-stream'
506 bcontent = f.read()
572 }[format]
507
573
508 if format != 'base64':
574 model.update(
509 try:
575 content=content,
510 model['content'] = bcontent.decode('utf8')
576 format=format,
511 except UnicodeError as e:
577 mimetype=mimetypes.guess_type(os_path)[0] or default_mime,
512 if format == 'text':
578 )
513 raise web.HTTPError(400, "%s is not UTF-8 encoded" % path, reason='bad format')
514 else:
515 model['format'] = 'text'
516 default_mime = 'text/plain'
517
518 if model['content'] is None:
519 model['content'] = base64.encodestring(bcontent).decode('ascii')
520 model['format'] = 'base64'
521 if model['format'] == 'base64':
522 default_mime = 'application/octet-stream'
523
524 model['mimetype'] = mimetypes.guess_type(os_path)[0] or default_mime
525
579
526 return model
580 return model
527
581
@@ -584,35 +638,6 b' class FileContentsManager(FileManagerMixin, ContentsManager):'
584 model = self._file_model(path, content=content, format=format)
638 model = self._file_model(path, content=content, format=format)
585 return model
639 return model
586
640
587 def _save_notebook(self, os_path, model, path):
588 """save a notebook file"""
589 nb = nbformat.from_dict(model['content'])
590 self.check_and_sign(nb, path)
591
592 # One checkpoint should always exist for notebooks.
593 if not self.checkpoint_manager.list_checkpoints(path):
594 self.checkpoint_manager.create_checkpoint(nb, path)
595
596 with self.atomic_writing(os_path, encoding='utf-8') as f:
597 nbformat.write(nb, f, version=nbformat.NO_CONVERT)
598
599 def _save_file(self, os_path, model, path=''):
600 """save a non-notebook file"""
601 fmt = model.get('format', None)
602 if fmt not in {'text', 'base64'}:
603 raise web.HTTPError(400, "Must specify format of file contents as 'text' or 'base64'")
604 try:
605 content = model['content']
606 if fmt == 'text':
607 bcontent = content.encode('utf8')
608 else:
609 b64_bytes = content.encode('ascii')
610 bcontent = base64.decodestring(b64_bytes)
611 except Exception as e:
612 raise web.HTTPError(400, u'Encoding error saving %s: %s' % (os_path, e))
613 with self.atomic_writing(os_path, text=False) as f:
614 f.write(bcontent)
615
616 def _save_directory(self, os_path, model, path=''):
641 def _save_directory(self, os_path, model, path=''):
617 """create a directory"""
642 """create a directory"""
618 if is_hidden(os_path, self.root_dir):
643 if is_hidden(os_path, self.root_dir):
@@ -640,9 +665,18 b' class FileContentsManager(FileManagerMixin, ContentsManager):'
640 self.log.debug("Saving %s", os_path)
665 self.log.debug("Saving %s", os_path)
641 try:
666 try:
642 if model['type'] == 'notebook':
667 if model['type'] == 'notebook':
643 self._save_notebook(os_path, model, path)
668 nb = nbformat.from_dict(model['content'])
669 self.check_and_sign(nb, path)
670 self._save_notebook(os_path, nb)
671 # One checkpoint should always exist for notebooks.
672 if not self.checkpoint_manager.list_checkpoints(path):
673 self.checkpoint_manager.create_notebook_checkpoint(
674 nb,
675 path,
676 )
644 elif model['type'] == 'file':
677 elif model['type'] == 'file':
645 self._save_file(os_path, model, path)
678 # Missing format will be handled internally by _save_file.
679 self._save_file(os_path, model['content'], model.get('format'))
646 elif model['type'] == 'directory':
680 elif model['type'] == 'directory':
647 self._save_directory(os_path, model, path)
681 self._save_directory(os_path, model, path)
648 else:
682 else:
@@ -34,15 +34,21 b' class CheckpointManager(LoggingConfigurable):'
34 """
34 """
35 Base class for managing checkpoints for a ContentsManager.
35 Base class for managing checkpoints for a ContentsManager.
36 """
36 """
37 def create_file_checkpoint(self, content, format, path):
38 """Create a checkpoint of the current state of a file
37
39
38 def create_checkpoint(self, nb, path):
40 Returns a checkpoint model for the new checkpoint.
41 """
42 raise NotImplementedError("must be implemented in a subclass")
43
44 def create_notebook_checkpoint(self, nb, path):
39 """Create a checkpoint of the current state of a file
45 """Create a checkpoint of the current state of a file
40
46
41 Returns a checkpoint_id for the new checkpoint.
47 Returns a checkpoint model for the new checkpoint.
42 """
48 """
43 raise NotImplementedError("must be implemented in a subclass")
49 raise NotImplementedError("must be implemented in a subclass")
44
50
45 def get_checkpoint_content(self, checkpoint_id, path):
51 def get_checkpoint(self, checkpoint_id, path, type):
46 """Get the content of a checkpoint.
52 """Get the content of a checkpoint.
47
53
48 Returns an unvalidated model with the same structure as
54 Returns an unvalidated model with the same structure as
@@ -496,9 +502,19 b' class ContentsManager(LoggingConfigurable):'
496 # Part 3: Checkpoints API
502 # Part 3: Checkpoints API
497 def create_checkpoint(self, path):
503 def create_checkpoint(self, path):
498 """Create a checkpoint."""
504 """Create a checkpoint."""
499
505 model = self.get(path, content=True)
500 nb = nbformat.from_dict(self.get(path, content=True)['content'])
506 type = model['type']
501 return self.checkpoint_manager.create_checkpoint(nb, path)
507 if type == 'notebook':
508 return self.checkpoint_manager.create_notebook_checkpoint(
509 model['content'],
510 path,
511 )
512 elif type == 'file':
513 return self.checkpoint_manager.create_file_checkpoint(
514 model['content'],
515 model['format'],
516 path,
517 )
502
518
503 def list_checkpoints(self, path):
519 def list_checkpoints(self, path):
504 return self.checkpoint_manager.list_checkpoints(path)
520 return self.checkpoint_manager.list_checkpoints(path)
@@ -507,17 +523,18 b' class ContentsManager(LoggingConfigurable):'
507 """
523 """
508 Restore a checkpoint.
524 Restore a checkpoint.
509 """
525 """
510 nb = self.checkpoint_manager.get_checkpoint_content(
526 type = self.get(path, content=False)['type']
527 content, format = self.checkpoint_manager.get_checkpoint(
511 checkpoint_id,
528 checkpoint_id,
512 path,
529 path,
530 type,
513 )
531 )
514
532
515 model = {
533 model = {
516 'content': nb,
534 'type': type,
517 'type': 'notebook',
535 'content': content,
536 'format': format,
518 }
537 }
519
520 self.validate_notebook_model(model)
521 return self.save(model, path)
538 return self.save(model, path)
522
539
523 def delete_checkpoint(self, checkpoint_id, path):
540 def delete_checkpoint(self, checkpoint_id, path):
@@ -542,6 +542,49 b' class APITest(NotebookTestBase):'
542 cps = self.api.get_checkpoints('foo/a.ipynb').json()
542 cps = self.api.get_checkpoints('foo/a.ipynb').json()
543 self.assertEqual(cps, [])
543 self.assertEqual(cps, [])
544
544
545 def test_file_checkpoints(self):
546 """
547 Test checkpointing of non-notebook files.
548 """
549 filename = 'foo/a.txt'
550 resp = self.api.read(filename)
551 orig_content = json.loads(resp.text)['content']
552
553 # Create a checkpoint.
554 r = self.api.new_checkpoint(filename)
555 self.assertEqual(r.status_code, 201)
556 cp1 = r.json()
557 self.assertEqual(set(cp1), {'id', 'last_modified'})
558 self.assertEqual(r.headers['Location'].split('/')[-1], cp1['id'])
559
560 # Modify the file and save.
561 new_content = orig_content + '\nsecond line'
562 model = {
563 'content': new_content,
564 'type': 'file',
565 'format': 'text',
566 }
567 resp = self.api.save(filename, body=json.dumps(model))
568
569 # List checkpoints
570 cps = self.api.get_checkpoints(filename).json()
571 self.assertEqual(cps, [cp1])
572
573 content = self.api.read(filename).json()['content']
574 self.assertEqual(content, new_content)
575
576 # Restore cp1
577 r = self.api.restore_checkpoint(filename, cp1['id'])
578 self.assertEqual(r.status_code, 204)
579 restored_content = self.api.read(filename).json()['content']
580 self.assertEqual(restored_content, orig_content)
581
582 # Delete cp1
583 r = self.api.delete_checkpoint(filename, cp1['id'])
584 self.assertEqual(r.status_code, 204)
585 cps = self.api.get_checkpoints(filename).json()
586 self.assertEqual(cps, [])
587
545 @contextmanager
588 @contextmanager
546 def patch_cp_root(self, dirname):
589 def patch_cp_root(self, dirname):
547 """
590 """
@@ -561,8 +604,13 b' class APITest(NotebookTestBase):'
561 using a different root dir from FileContentsManager. This also keeps
604 using a different root dir from FileContentsManager. This also keeps
562 the implementation honest for use with ContentsManagers that don't map
605 the implementation honest for use with ContentsManagers that don't map
563 models to the filesystem
606 models to the filesystem
564 """
565
607
608 Override this method to a no-op when testing other managers.
609 """
566 with TemporaryDirectory() as td:
610 with TemporaryDirectory() as td:
567 with self.patch_cp_root(td):
611 with self.patch_cp_root(td):
568 self.test_checkpoints()
612 self.test_checkpoints()
613
614 with TemporaryDirectory() as td:
615 with self.patch_cp_root(td):
616 self.test_file_checkpoints()
@@ -85,10 +85,10 b' class TestFileContentsManager(TestCase):'
85 os.mkdir(os.path.join(td, subd))
85 os.mkdir(os.path.join(td, subd))
86 fm = FileContentsManager(root_dir=root)
86 fm = FileContentsManager(root_dir=root)
87 cpm = fm.checkpoint_manager
87 cpm = fm.checkpoint_manager
88 cp_dir = cpm.get_checkpoint_path(
88 cp_dir = cpm.checkpoint_path(
89 'cp', 'test.ipynb'
89 'cp', 'test.ipynb'
90 )
90 )
91 cp_subdir = cpm.get_checkpoint_path(
91 cp_subdir = cpm.checkpoint_path(
92 'cp', '/%s/test.ipynb' % subd
92 'cp', '/%s/test.ipynb' % subd
93 )
93 )
94 self.assertNotEqual(cp_dir, cp_subdir)
94 self.assertNotEqual(cp_dir, cp_subdir)
General Comments 0
You need to be logged in to leave comments. Login now