diff --git a/fimfarchive/writers.py b/fimfarchive/writers.py new file mode 100644 index 0000000..0147647 --- /dev/null +++ b/fimfarchive/writers.py @@ -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 . +# + + +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) diff --git a/tests/test_writers.py b/tests/test_writers.py new file mode 100644 index 0000000..2ff0e79 --- /dev/null +++ b/tests/test_writers.py @@ -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 . +# + + +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()