Show More
@@ -26,6 +26,7 b' from tornado import web' | |||||
26 |
|
26 | |||
27 | from .nbmanager import NotebookManager |
|
27 | from .nbmanager import NotebookManager | |
28 | from IPython.nbformat import current |
|
28 | from IPython.nbformat import current | |
|
29 | from IPython.utils.io import atomic_writing | |||
29 | from IPython.utils.traitlets import Unicode, Bool, TraitError |
|
30 | from IPython.utils.traitlets import Unicode, Bool, TraitError | |
30 | from IPython.utils.py3compat import getcwd |
|
31 | from IPython.utils.py3compat import getcwd | |
31 | from IPython.utils import tz |
|
32 | from IPython.utils import tz | |
@@ -304,7 +305,7 b' class FileNotebookManager(NotebookManager):' | |||||
304 | nb['metadata']['name'] = u'' |
|
305 | nb['metadata']['name'] = u'' | |
305 | try: |
|
306 | try: | |
306 | self.log.debug("Autosaving notebook %s", os_path) |
|
307 | self.log.debug("Autosaving notebook %s", os_path) | |
307 |
with |
|
308 | with atomic_writing(os_path, encoding='utf-8') as f: | |
308 | current.write(nb, f, u'json') |
|
309 | current.write(nb, f, u'json') | |
309 | except Exception as e: |
|
310 | except Exception as e: | |
310 | raise web.HTTPError(400, u'Unexpected error while autosaving notebook: %s %s' % (os_path, e)) |
|
311 | raise web.HTTPError(400, u'Unexpected error while autosaving notebook: %s %s' % (os_path, e)) | |
@@ -314,7 +315,7 b' class FileNotebookManager(NotebookManager):' | |||||
314 | py_path = os.path.splitext(os_path)[0] + '.py' |
|
315 | py_path = os.path.splitext(os_path)[0] + '.py' | |
315 | self.log.debug("Writing script %s", py_path) |
|
316 | self.log.debug("Writing script %s", py_path) | |
316 | try: |
|
317 | try: | |
317 |
with |
|
318 | with atomic_writing(py_path, encoding='utf-8') as f: | |
318 | current.write(nb, f, u'py') |
|
319 | current.write(nb, f, u'py') | |
319 | except Exception as e: |
|
320 | except Exception as e: | |
320 | raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s %s' % (py_path, e)) |
|
321 | raise web.HTTPError(400, u'Unexpected error while saving notebook as script: %s %s' % (py_path, e)) |
@@ -16,6 +16,8 b' from __future__ import absolute_import' | |||||
16 | # Imports |
|
16 | # Imports | |
17 | #----------------------------------------------------------------------------- |
|
17 | #----------------------------------------------------------------------------- | |
18 | import codecs |
|
18 | import codecs | |
|
19 | from contextlib import contextmanager | |||
|
20 | import io | |||
19 | import os |
|
21 | import os | |
20 | import sys |
|
22 | import sys | |
21 | import tempfile |
|
23 | import tempfile | |
@@ -217,6 +219,61 b" def temp_pyfile(src, ext='.py'):" | |||||
217 | f.flush() |
|
219 | f.flush() | |
218 | return fname, f |
|
220 | return fname, f | |
219 |
|
221 | |||
|
222 | @contextmanager | |||
|
223 | def atomic_writing(path, text=True, encoding='utf-8', **kwargs): | |||
|
224 | """Context manager to write to a file only if the entire write is successful. | |||
|
225 | ||||
|
226 | This works by creating a temporary file in the same directory, and renaming | |||
|
227 | it over the old file if the context is exited without an error. If the | |||
|
228 | target file is a symlink or a hardlink, this will not be preserved: it will | |||
|
229 | be replaced by a new regular file. | |||
|
230 | ||||
|
231 | On Windows, there is a small chink in the atomicity: the target file is | |||
|
232 | deleted before renaming the temporary file over it. This appears to be | |||
|
233 | unavoidable. | |||
|
234 | ||||
|
235 | Parameters | |||
|
236 | ---------- | |||
|
237 | path : str | |||
|
238 | The target file to write to. | |||
|
239 | ||||
|
240 | text : bool, optional | |||
|
241 | Whether to open the file in text mode (i.e. to write unicode). Default is | |||
|
242 | True. | |||
|
243 | ||||
|
244 | encoding : str, optional | |||
|
245 | The encoding to use for files opened in text mode. Default is UTF-8. | |||
|
246 | ||||
|
247 | **kwargs | |||
|
248 | Passed to :func:`io.open`. | |||
|
249 | """ | |||
|
250 | dirname, basename = os.path.split(path) | |||
|
251 | handle, tmp_path = tempfile.mkstemp(prefix=basename, dir=dirname, text=text) | |||
|
252 | if text: | |||
|
253 | fileobj = io.open(handle, 'w', encoding=encoding, **kwargs) | |||
|
254 | else: | |||
|
255 | fileobj = io.open(handle, 'wb', **kwargs) | |||
|
256 | ||||
|
257 | try: | |||
|
258 | yield fileobj | |||
|
259 | except: | |||
|
260 | fileobj.close() | |||
|
261 | os.remove(tmp_path) | |||
|
262 | raise | |||
|
263 | ||||
|
264 | # Flush to disk | |||
|
265 | fileobj.flush() | |||
|
266 | os.fsync(fileobj.fileno()) | |||
|
267 | ||||
|
268 | # Written successfully, now rename it | |||
|
269 | fileobj.close() | |||
|
270 | ||||
|
271 | if os.name == 'nt' and os.path.exists(path): | |||
|
272 | # Rename over existing file doesn't work on Windows | |||
|
273 | os.remove(path) | |||
|
274 | ||||
|
275 | os.rename(tmp_path, path) | |||
|
276 | ||||
220 |
|
277 | |||
221 | def raw_print(*args, **kw): |
|
278 | def raw_print(*args, **kw): | |
222 | """Raw print to sys.__stdout__, otherwise identical interface to print().""" |
|
279 | """Raw print to sys.__stdout__, otherwise identical interface to print().""" |
@@ -15,6 +15,7 b' from __future__ import print_function' | |||||
15 | from __future__ import absolute_import |
|
15 | from __future__ import absolute_import | |
16 |
|
16 | |||
17 | import io as stdlib_io |
|
17 | import io as stdlib_io | |
|
18 | import os.path | |||
18 | import sys |
|
19 | import sys | |
19 |
|
20 | |||
20 | from subprocess import Popen, PIPE |
|
21 | from subprocess import Popen, PIPE | |
@@ -23,8 +24,11 b' import unittest' | |||||
23 | import nose.tools as nt |
|
24 | import nose.tools as nt | |
24 |
|
25 | |||
25 | from IPython.testing.decorators import skipif |
|
26 | from IPython.testing.decorators import skipif | |
26 | from IPython.utils.io import Tee, capture_output, unicode_std_stream |
|
27 | from IPython.utils.io import (Tee, capture_output, unicode_std_stream, | |
|
28 | atomic_writing, | |||
|
29 | ) | |||
27 | from IPython.utils.py3compat import doctest_refactor_print, PY3 |
|
30 | from IPython.utils.py3compat import doctest_refactor_print, PY3 | |
|
31 | from IPython.utils.tempdir import TemporaryDirectory | |||
28 |
|
32 | |||
29 | if PY3: |
|
33 | if PY3: | |
30 | from io import StringIO |
|
34 | from io import StringIO | |
@@ -121,4 +125,27 b' def test_UnicodeStdStream_nowrap():' | |||||
121 | nt.assert_is(unicode_std_stream(), sys.stdout) |
|
125 | nt.assert_is(unicode_std_stream(), sys.stdout) | |
122 | assert not sys.stdout.closed |
|
126 | assert not sys.stdout.closed | |
123 | finally: |
|
127 | finally: | |
124 | sys.stdout = orig_stdout No newline at end of file |
|
128 | sys.stdout = orig_stdout | |
|
129 | ||||
|
130 | def test_atomic_writing(): | |||
|
131 | class CustomExc(Exception): pass | |||
|
132 | ||||
|
133 | with TemporaryDirectory() as td: | |||
|
134 | f1 = os.path.join(td, 'penguin') | |||
|
135 | with stdlib_io.open(f1, 'w') as f: | |||
|
136 | f.write(u'Before') | |||
|
137 | ||||
|
138 | with nt.assert_raises(CustomExc): | |||
|
139 | with atomic_writing(f1) as f: | |||
|
140 | f.write(u'Failing write') | |||
|
141 | raise CustomExc | |||
|
142 | ||||
|
143 | # Because of the exception, the file should not have been modified | |||
|
144 | with stdlib_io.open(f1, 'r') as f: | |||
|
145 | nt.assert_equal(f.read(), u'Before') | |||
|
146 | ||||
|
147 | with atomic_writing(f1) as f: | |||
|
148 | f.write(u'Overwritten') | |||
|
149 | ||||
|
150 | with stdlib_io.open(f1, 'r') as f: | |||
|
151 | nt.assert_equal(f.read(), u'Overwritten') |
General Comments 0
You need to be logged in to leave comments.
Login now