Add writers module

This commit is contained in:
Joakim Soderlund 2017-01-08 00:38:38 +01:00
parent db1b1fdadc
commit 3ef4a7aecc
2 changed files with 334 additions and 0 deletions

181
fimfarchive/writers.py Normal file
View file

@ -0,0 +1,181 @@
"""
Writers for Fimfarchive.
"""
#
# Fimfarchive, preserves stories from Fimfiction.
# Copyright (C) 2015 Joakim Soderlund
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import json
import os
from fimfarchive.mappers import StaticMapper, StoryPathMapper
__all__ = (
'Writer',
'DirectoryWriter',
)
class Writer():
"""
Abstract base class for story writers.
"""
def write(self, story):
"""
Saves the story to somewhere.
Args:
story: Intance of the `Story` class.
throws:
IOError: If writing the story failed.
"""
raise NotImplementedError()
class DirectoryWriter(Writer):
"""
Writes story meta and data to file system.
"""
def __init__(
self, meta_path=None, data_path=None,
overwrite=False, make_dirs=True):
"""
Constructor.
Writing of meta and data can be enabled by setting either of
the path parameters. If both path parameters are set to None,
then this writer instance will essentially be doing nothing.
Args:
meta_path: Directory path, or callable returning a path.
data_path: Directory path, or callable returning a path.
overwrite: Enable to overwrite already existing files.
make_dirs: Enable to create parent directories.
"""
self.meta_path = self.get_mapper(meta_path)
self.data_path = self.get_mapper(data_path)
self.overwrite = overwrite
self.make_dirs = make_dirs
def get_mapper(self, obj):
"""
Returns a callable for mapping story to file path.
"""
if callable(obj):
return obj
elif isinstance(obj, str):
return StoryPathMapper(obj)
elif obj is None:
return StaticMapper(obj)
else:
raise TypeError("Path must be callable or string.")
def check_overwrite(self, path):
"""
Checks that a file is not overwritten unless requested.
Args:
path: File path which is to be written to.
Raises:
FileExistsError: If overwrite is disabled and path exists.
"""
if not self.overwrite and os.path.exists(path):
raise FileExistsError("Would overwrite: '{}'." .format(path))
def check_directory(self, path):
"""
Checks that the path's parent directory exists.
The parent directory can optionally be created if not.
Args:
path: File path which is to be written to.
Raises:
IOError: If the parent directory is a file.
FileNotFoundError: If the parent directory does not exist,
and if directory creation has been disabled.
"""
parent = os.path.dirname(path)
if os.path.isdir(parent):
return
elif self.make_dirs:
os.makedirs(parent)
else:
raise FileNotFoundError(parent)
def perform_write(self, contents, path):
"""
Performs the actual file write.
Args:
contents: Bytes to write.
path: File path to write to.
"""
self.check_overwrite(path)
self.check_directory(path)
with open(path, 'wb') as fobj:
fobj.write(contents)
def write_meta(self, story, path):
"""
Prepares the story meta for writing.
Args:
story: Story containing the meta.
path: File path to write to.
"""
text = json.dumps(
story.meta,
indent=4,
sort_keys=True,
ensure_ascii=False,
)
contents = text.encode('utf-8')
self.perform_write(contents, path)
def write_data(self, story, path):
"""
Prepares the story data for writing.
Args:
story: Story containing the data.
path: File path to write to.
"""
contents = story.data
self.perform_write(contents, path)
def write(self, story):
meta_path = self.meta_path(story)
data_path = self.data_path(story)
if meta_path:
self.write_meta(story, meta_path)
if data_path:
self.write_data(story, data_path)

153
tests/test_writers.py Normal file
View file

@ -0,0 +1,153 @@
"""
Writer tests.
"""
#
# Fimfarchive, preserves stories from Fimfiction.
# Copyright (C) 2015 Joakim Soderlund
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import json
import os
import pytest
from fimfarchive.mappers import StoryPathMapper
from fimfarchive.writers import DirectoryWriter
class TestDirectoryWriter:
"""
DirectoryWriter tests.
"""
@pytest.fixture
def mapper(self, tmpdir):
"""
Returns a story path mapper for a temporary directory.
"""
return StoryPathMapper(tmpdir)
def test_story_meta_is_written(self, story, mapper):
"""
Tests story meta is written in its entirety.
"""
writer = DirectoryWriter(meta_path=mapper)
writer.write(story)
with open(mapper(story), 'rt') as fobj:
assert story.meta == json.load(fobj)
def test_story_data_is_written(self, story, mapper):
"""
Tests story data is written in its entirety.
"""
writer = DirectoryWriter(data_path=mapper)
writer.write(story)
with open(mapper(story), 'rb') as fobj:
assert story.data == fobj.read()
def test_string_paths_become_mappers(self, tmpdir):
"""
Tests `StoryPathMapper` instances are created for string paths.
"""
writer = DirectoryWriter(meta_path='meta', data_path='data')
assert isinstance(writer.meta_path, StoryPathMapper)
assert isinstance(writer.data_path, StoryPathMapper)
def test_rejects_integer_path(self):
"""
Tests `TypeError` is raised for invalid path types.
"""
with pytest.raises(TypeError):
DirectoryWriter(meta_path=1)
def test_parent_directory_creation(self, story, tmpdir):
"""
Tests parent directory is created by default.
"""
directory = str(tmpdir.join('meta'))
writer = DirectoryWriter(meta_path=directory)
writer.write(story)
assert os.path.isdir(directory)
def test_disable_directory_creation(self, story, tmpdir):
"""
Tests `FileNotFoundError` is raised if directory creation is disabled.
"""
directory = str(tmpdir.join('meta'))
writer = DirectoryWriter(meta_path=directory, make_dirs=False)
with pytest.raises(FileNotFoundError):
writer.write(story)
def test_refuse_meta_overwrite(self, story, mapper):
"""
Tests raises `FileExistsError` on meta unless overwrite is enabled.
"""
writer = DirectoryWriter(meta_path=mapper)
with open(mapper(story), 'at'):
pass
with pytest.raises(FileExistsError):
writer.write(story)
def test_refuse_data_overwrite(self, story, mapper):
"""
Tests raises `FileExistsError` on data unless overwrite is enabled.
"""
writer = DirectoryWriter(data_path=mapper)
with open(mapper(story), 'ab'):
pass
with pytest.raises(FileExistsError):
writer.write(story)
def test_overwrites_when_enabled(self, story, tmpdir):
"""
Tests overwrites meta and data when requested.
"""
meta_path = StoryPathMapper(tmpdir.mkdir('meta'))
data_path = StoryPathMapper(tmpdir.mkdir('data'))
writer = DirectoryWriter(
meta_path=meta_path,
data_path=data_path,
overwrite=True,
)
with open(meta_path(story), 'wt'):
pass
with open(data_path(story), 'wb'):
pass
writer.write(story)
with open(meta_path(story), 'rt') as fobj:
assert story.meta == json.load(fobj)
with open(data_path(story), 'rb') as fobj:
assert story.data == fobj.read()