diff --git a/fimfarchive/converters/__init__.py b/fimfarchive/converters/__init__.py index 813a406..85abf57 100644 --- a/fimfarchive/converters/__init__.py +++ b/fimfarchive/converters/__init__.py @@ -24,6 +24,7 @@ Converter module. from .base import Converter from .alpha_beta import AlphaBetaConverter +from .fpub_epub import FpubEpubConverter from .json_fpub import JsonFpubConverter from .local_utc import LocalUtcConverter @@ -31,6 +32,7 @@ from .local_utc import LocalUtcConverter __all__ = ( 'Converter', 'AlphaBetaConverter', + 'FpubEpubConverter', 'JsonFpubConverter', 'LocalUtcConverter', ) diff --git a/fimfarchive/converters/fpub_epub.py b/fimfarchive/converters/fpub_epub.py new file mode 100644 index 0000000..d3a2bf4 --- /dev/null +++ b/fimfarchive/converters/fpub_epub.py @@ -0,0 +1,100 @@ +""" +FPUB to EPUB converter for story data. +""" + + +# +# Fimfarchive, preserves stories from Fimfiction. +# Copyright (C) 2019 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 . +# + + +from functools import partial +from pathlib import Path +from shutil import rmtree +from subprocess import DEVNULL, STDOUT, run +from tempfile import mkdtemp +from typing import Union + +from fimfarchive.flavors import DataFormat +from fimfarchive.stories import Story +from fimfarchive.utils import get_path + +from .base import Converter + + +__all__ = ( + 'FpubEpubConverter', +) + + +SOURCE = 'source.epub' +TARGET = 'target.epub' +TIMEOUT = 300 + +PROGRAM = 'ebook-convert' +ARGUMENTS = ('--no-default-epub-cover',) + +proc = partial(run, stderr=STDOUT, timeout=TIMEOUT, check=True) + + +def ebook_convert(data: bytes, pipe: int) -> bytes: + """ + Calls the external ebook-convert program. + """ + parent = Path(mkdtemp()) + source = parent / SOURCE + target = parent / TARGET + + command = (PROGRAM, str(source), str(target), *ARGUMENTS) + + try: + source.write_bytes(data) + proc(command, stdout=pipe) + except Exception: + raise + else: + return target.read_bytes() + finally: + rmtree(parent) + + +class FpubEpubConverter(Converter): + """ + Converts story data from FPUB to EPUB. + """ + + def __init__(self, logdir: Union[Path, str] = None) -> None: + self.logdir = get_path(logdir) + + if self.logdir and not self.logdir.is_dir(): + raise ValueError("Logdir must be a directory.") + + def __call__(self, story: Story) -> Story: + if DataFormat.FPUB not in story.flavors: + raise ValueError(f"Missing flavor: {DataFormat.FPUB}") + + if self.logdir is not None: + with open(self.logdir / str(story.key), 'a') as fobj: + data = ebook_convert(story.data, fobj.fileno()) + else: + data = ebook_convert(story.data, DEVNULL) + + flavors = set(story.flavors) + flavors.remove(DataFormat.FPUB) + flavors.add(DataFormat.EPUB) + + return story.merge(data=data, flavors=flavors) diff --git a/tests/converters/test_fpub_epub.py b/tests/converters/test_fpub_epub.py new file mode 100644 index 0000000..511fc28 --- /dev/null +++ b/tests/converters/test_fpub_epub.py @@ -0,0 +1,143 @@ +""" +FPUB to EPUB converter tests. +""" + + +# +# Fimfarchive, preserves stories from Fimfiction. +# Copyright (C) 2019 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 . +# + + +from os import urandom, write +from pathlib import Path +from subprocess import DEVNULL +from unittest.mock import patch + +import pytest + +from fimfarchive.converters import fpub_epub, FpubEpubConverter +from fimfarchive.flavors import DataFormat + + +class TestFpubEpubConverter: + """ + FpubEpubConverter tests. + """ + + @pytest.fixture + def fpub(self): + """ + Returns bytes simulating FPUB data. + """ + return urandom(16) + + @pytest.fixture + def epub(self): + """ + Returns bytes simulating EPUB data. + """ + return urandom(16) + + @pytest.fixture + def log(self): + """ + Returns bytes simulating log data. + """ + return urandom(16) + + @pytest.fixture + def calibre(self, fpub, epub, log): + """ + Returns a function simulating Calibre. + """ + def function(*args, **kwargs): + source = Path(args[0][1]) + target = Path(args[0][2]) + stdout = kwargs['stdout'] + + if 0 <= stdout: + write(stdout, log) + + if fpub == source.read_bytes(): + target.write_bytes(epub) + + return function + + @pytest.fixture + def proc(self, calibre): + """ + Yeilds a mock for simulating process calls. + """ + with patch.object(fpub_epub, 'proc') as mock: + mock.side_effect = calibre + yield mock + + @pytest.fixture + def story(self, story, fpub): + """ + Returns an FPUB story instance. + """ + return story.merge(data=fpub, flavors=[DataFormat.FPUB]) + + def verify_call(self, call, pipe): + """ + Verifies process call arguments. + """ + name, args, kwargs = call + program, source, target, cover = args[0] + stdout = kwargs['stdout'] + + assert 1 == len(args) + assert 1 == len(kwargs) + + assert 'ebook-convert' == program + assert 'source.epub' == Path(source).name + assert 'target.epub' == Path(target).name + assert '--no-default-epub-cover' == cover + + assert pipe is (DEVNULL != stdout) + + def test_without_log(self, story, fpub, epub, proc): + """ + Tests convertion without logging. + """ + convert = FpubEpubConverter() + output = convert(story) + calls = proc.mock_calls + + assert 1 == len(calls) + assert fpub == story.data + assert epub == output.data + + self.verify_call(calls[0], False) + + def test_with_log(self, tmpdir, story, fpub, epub, log, proc): + """ + Tests convertion with logging. + """ + tmppath = Path(str(tmpdir)) + convert = FpubEpubConverter(tmppath) + logfile = tmppath / str(story.key) + output = convert(story) + calls = proc.mock_calls + + assert 1 == len(calls) + assert fpub == story.data + assert epub == output.data + assert log == logfile.read_bytes() + + self.verify_call(calls[0], True)