Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle errors when saving files gracefully #120

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ This document records all notable changes to Watson. This project adheres to
timespan to the current year, month, week or day.
* Added: Zsh completion support
* Added: document installation via homebrew on OS X
* Updated: when saving the Watson frames, state or config file, the most recent
previous version of the file is kept as a back up.
* Fixed: bash completion of projects and tags with spaces in them
* Fixed: if saving the Watson frames, state or config file fails for any
reason, the original is kept (and not wiped as before).

## 1.3.2 (2016-03-01)

Expand Down
96 changes: 93 additions & 3 deletions tests/test_watson.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
import mock

try:
from io import StringIO
except ImportError:
from StringIO import StringIO
except ImportError:
from io import StringIO
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍, io.StringIO doesn't have the right behavior in Python 2.7. This is very confusing.


import py
import pytest
Expand All @@ -23,7 +23,8 @@
from click import get_app_dir
from watson import Watson, WatsonError
from watson.watson import ConfigurationError, ConfigParser
from watson.utils import get_start_time_for_period
from watson.utils import get_start_time_for_period, make_json_writer, safe_save


TEST_FIXTURE_DIR = py.path.local(
os.path.dirname(
Expand Down Expand Up @@ -630,6 +631,20 @@ def test_save_empty_last_sync(config_dir):
assert json_mock.call_args[0][0] == 0


def test_watson_save_calls_safe_save(watson, config_dir):
frames_file = os.path.join(config_dir, 'frames')
watson.start('foo', tags=['A', 'B'])
watson.stop()

with mock.patch('watson.watson.safe_save') as save_mock:
watson.save()

assert watson._frames.changed
assert save_mock.call_count == 1
assert len(save_mock.call_args[0]) == 2
assert save_mock.call_args[0][0] == frames_file


# push

def test_push_with_no_config(watson):
Expand Down Expand Up @@ -881,3 +896,78 @@ def test_merge_report(watson, datafiles):
def test_get_start_time_for_period(now, mode, start_time):
with mock_datetime(now, datetime):
assert get_start_time_for_period(mode).datetime == start_time


# utils

def test_make_json_writer():
fp = StringIO()
writer = make_json_writer(lambda: {'foo': 42})
writer(fp)
assert fp.getvalue() == '{\n "foo": 42\n}'


def test_make_json_writer_with_args():
fp = StringIO()
writer = make_json_writer(lambda x: {'foo': x}, 23)
writer(fp)
assert fp.getvalue() == '{\n "foo": 23\n}'


def test_make_json_writer_with_kwargs():
fp = StringIO()
writer = make_json_writer(lambda foo=None: {'foo': foo}, foo='bar')
writer(fp)
assert fp.getvalue() == '{\n "foo": "bar"\n}'


def test_safe_save(config_dir):
save_file = os.path.join(config_dir, 'test')
backup_file = os.path.join(config_dir, 'test' + '.bak')

assert not os.path.exists(save_file)
safe_save(save_file, lambda f: f.write("Success"))
assert os.path.exists(save_file)
assert not os.path.exists(backup_file)

with open(save_file) as fp:
assert fp.read() == "Success"

safe_save(save_file, "Again")
assert os.path.exists(backup_file)

with open(save_file) as fp:
assert fp.read() == "Again"

with open(backup_file) as fp:
assert fp.read() == "Success"

assert os.path.getmtime(save_file) >= os.path.getmtime(backup_file)


def test_safe_save_with_exception(config_dir):
save_file = os.path.join(config_dir, 'test')
backup_file = os.path.join(config_dir, 'test' + '.bak')

def failing_writer(f):
raise RuntimeError("Save failed.")

assert not os.path.exists(save_file)

with pytest.raises(RuntimeError):
safe_save(save_file, failing_writer)

assert not os.path.exists(save_file)
assert not os.path.exists(backup_file)

safe_save(save_file, lambda f: f.write("Success"))
assert os.path.exists(save_file)
assert not os.path.exists(backup_file)

with pytest.raises(RuntimeError):
safe_save(save_file, failing_writer)

with open(save_file) as fp:
assert fp.read() == "Success"

assert not os.path.exists(backup_file)
23 changes: 14 additions & 9 deletions watson/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
import arrow
import click

from . import watson
from . import watson as _watson
from .frames import Frame
from .utils import (format_timedelta, get_frame_from_argument, options,
sorted_groupby, style, get_start_time_for_period)
from .utils import (format_timedelta, get_frame_from_argument,
get_start_time_for_period, options, safe_save,
sorted_groupby, style)


class MutuallyExclusiveOption(click.Option):
Expand Down Expand Up @@ -43,7 +44,7 @@ def format_message(self):
return style('error', self.message)


watson.WatsonError = WatsonCliError
_watson.WatsonError = WatsonCliError


class DateParamType(click.ParamType):
Expand All @@ -63,7 +64,7 @@ def convert(self, value, param, ctx):


@click.group()
@click.version_option(version=watson.__version__, prog_name='Watson')
@click.version_option(version=_watson.__version__, prog_name='Watson')
@click.pass_context
def cli(ctx):
"""
Expand All @@ -76,7 +77,7 @@ def cli(ctx):

# This is the main command group, needed by click in order
# to handle the subcommands
ctx.obj = watson.Watson(config_dir=os.environ.get('WATSON_DIR'))
ctx.obj = _watson.Watson(config_dir=os.environ.get('WATSON_DIR'))


@cli.command()
Expand Down Expand Up @@ -811,15 +812,19 @@ def config(context, key, value, edit):
config = watson.config

if edit:
click.edit(filename=watson.config_file, extension='.ini')
with open(watson.config_file) as fp:
newconfig = click.edit(text=fp.read(), extension='.ini')

if newconfig:
safe_save(watson.config_file, newconfig)

try:
watson.config = None
watson.config
except WatsonCliError:
except _watson.ConfigurationError as exc:
watson.config = config
watson.save()
raise
raise WatsonCliError(str(exc))
return

if not key:
Expand Down
61 changes: 60 additions & 1 deletion watson/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import itertools
import datetime
import itertools
import json
import os
import tempfile

import click
import arrow

from click.exceptions import UsageError


try:
text_type = (str, unicode)
except NameError:
text_type = str


def style(name, element):
def _style_tags(tags):
if not tags:
Expand Down Expand Up @@ -138,3 +147,53 @@ def get_start_time_for_period(period):
raise ValueError('Unsupported period value: {}'.format(period))

return start_time


def make_json_writer(func, *args, **kwargs):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get it, why expecting a function and not an object?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to handle the two cases of config.write(fp) and json.dump(data, fp) with a common interface, so safe_save expects a function, which takes a file-object and knows how to write some data to it. This can be config.write, for example, or the function returned by make_json_writer.

I wanted to avoid having to store the data, which the function writes, in memory, so instead of passing the data to make_json_writer, you pass a function, which generates the data to write, for example frames.dump. The effect is that the function, which generates the data, is only called at the moment the writer function passed to safe_save is called. If the data generation function is a e.g. a generator and the writer function can take advantage of it, this makes writing more efficient than passing the whole lump of data as a variable.

"""
Return a function that receives a file-like object and writes the return
value of func(*args, **kwargs) as JSON to it.
"""
def writer(f):
json.dump(func(*args, **kwargs), f, indent=1, ensure_ascii=False)
return writer


def safe_save(path, content, ext='.bak'):
"""
Save given content to file at given path safely.

`content` may either be a (unicode) string to write to the file, or a
function taking one argument, a file object opened for writing. The
function may write (unicode) strings to the file object (but doesn't need
to close it).

The file to write to is created at a temporary location first. If there is
an error creating or writing to the temp file or calling `content`, the
destination file is left untouched. Otherwise, if all is well, an existing
destination file is backed up to `path` + `ext` (defaults to '.bak') and
the temporary file moved into its place.

"""
tmpfp = tempfile.NamedTemporaryFile(mode='w+', delete=False)
try:
with tmpfp:
if isinstance(content, text_type):
tmpfp.write(content)
else:
content(tmpfp)
except:
try:
os.unlink(tmpfp.name)
except (IOError, OSError):
pass
raise
else:
if os.path.exists(path):
try:
os.unlink(path + ext)
except OSError:
pass
os.rename(path, path + ext)

os.rename(tmpfp.name, path)
18 changes: 8 additions & 10 deletions watson/watson.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@

from .config import ConfigParser
from .frames import Frames
from .utils import make_json_writer, safe_save
from .version import version as __version__ # noqa


class WatsonError(RuntimeError):
pass


class ConfigurationError(WatsonError, configparser.Error):
class ConfigurationError(configparser.Error, WatsonError):
pass


Expand Down Expand Up @@ -147,21 +148,18 @@ def save(self):
else:
current = {}

with open(self.state_file, 'w+') as f:
json.dump(current, f, indent=1, ensure_ascii=False)
safe_save(self.state_file, make_json_writer(lambda: current))

if self._frames is not None and self._frames.changed:
with open(self.frames_file, 'w+') as f:
json.dump(self.frames.dump(), f, indent=1,
ensure_ascii=False)
safe_save(self.frames_file,
make_json_writer(self.frames.dump))

if self._config_changed:
with open(self.config_file, 'w+') as f:
self.config.write(f)
safe_save(self.config_file, self.config.write)

if self._last_sync is not None:
with open(self.last_sync_file, 'w+') as f:
json.dump(self._format_date(self.last_sync), f)
safe_save(self.last_sync_file,
make_json_writer(self._format_date, self.last_sync))
except OSError as e:
raise WatsonError(
"Impossible to write {}: {}".format(e.filename, e)
Expand Down