diff --git a/fimfarchive/fetchers/__init__.py b/fimfarchive/fetchers/__init__.py index e3b022d..ae49643 100644 --- a/fimfarchive/fetchers/__init__.py +++ b/fimfarchive/fetchers/__init__.py @@ -23,12 +23,14 @@ Story fetchers. from .base import Fetcher +from .directory import DirectoryFetcher from .fimfarchive import FimfarchiveFetcher from .fimfiction import FimfictionFetcher __all__ = ( 'Fetcher', + 'DirectoryFetcher', 'FimfarchiveFetcher', 'FimfictionFetcher', ) diff --git a/fimfarchive/fetchers/directory.py b/fimfarchive/fetchers/directory.py new file mode 100644 index 0000000..59ae750 --- /dev/null +++ b/fimfarchive/fetchers/directory.py @@ -0,0 +1,97 @@ +""" +Directory fetcher. +""" + + +# +# 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 typing import Any, Dict, Iterable + +from fimfarchive.exceptions import InvalidStoryError, StorySourceError +from fimfarchive.flavors import Flavor + +from .base import Fetcher + + +__all__ = ( + 'DirectoryFetcher', +) + + +class DirectoryFetcher(Fetcher): + """ + Fetches stories from file system. + """ + prefetch_meta = True + prefetch_data = False + + def __init__( + self, + meta_path: str, + data_path: str, + flavors: Iterable[Flavor], + ) -> None: + """ + Constructor. + + Args: + meta: The directory for story meta. + data: The directory for story data. + flavors: The flavors to add to stories. + """ + self.meta_path = meta_path + self.data_path = data_path + self.flavors = frozenset(flavors) + + def read_file(self, path: str) -> bytes: + """ + Reads file data for the path. + + Args: + path: The path to read from. + + Returns: + The file contents as bytes. + + Raises: + InvalidStoryError: If the file does not exist. + StorySourceError: If the file could not be read. + """ + try: + with open(path, 'rb') as fobj: + return fobj.read() + except FileNotFoundError as e: + raise InvalidStoryError("File does not exist.") from e + except Exception as e: + raise StorySourceError("Unable to read file.") from e + + def fetch_data(self, key: int) -> bytes: + path = os.path.join(self.data_path, str(key)) + raw = self.read_file(path) + + return raw + + def fetch_meta(self, key: int) -> Dict[str, Any]: + path = os.path.join(self.meta_path, str(key)) + raw = self.read_file(path) + + return json.loads(raw.decode()) diff --git a/tests/fetchers/test_directory.py b/tests/fetchers/test_directory.py new file mode 100644 index 0000000..055009b --- /dev/null +++ b/tests/fetchers/test_directory.py @@ -0,0 +1,149 @@ +""" +Directory fetcher 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 pytest +from typing import Any, Dict + +from fimfarchive.exceptions import InvalidStoryError +from fimfarchive.fetchers import DirectoryFetcher + + +NONE_KEY = 1 +META_KEY = 3 +DATA_KEY = 5 +BOTH_KEY = 7 + + +class TestDirectoryFetcher: + """ + DirectoryFetcher tests. + """ + + def make_meta(self, key: int) -> Dict[str, Any]: + """ + Returns generated story meta for the key. + """ + return {'id': key} + + @pytest.fixture + def metadir(self, tmpdir) -> str: + """ + Returns a temporary meta directory path. + """ + subdir = tmpdir.mkdir('meta') + + for key in (META_KEY, BOTH_KEY): + meta = self.make_meta(key) + path = subdir.join(str(key)) + path.write(json.dumps(meta)) + + return str(subdir) + + def make_data(self, key: int) -> bytes: + """ + Returns generated story data for the key. + """ + return f'STORY {key}'.encode() + + @pytest.fixture + def datadir(self, tmpdir) -> str: + """ + Returns a temporary data directory path. + """ + subdir = tmpdir.mkdir('data') + + for key in (DATA_KEY, BOTH_KEY): + data = self.make_data(key) + path = subdir.join(str(key)) + path.write(data) + + return str(subdir) + + @pytest.fixture + def fetcher(self, metadir, datadir, flavor) -> DirectoryFetcher: + """ + Returns a directory fetcher instance with prefetch disabled. + """ + fetcher = DirectoryFetcher(metadir, datadir, [flavor]) + + fetcher.prefetch_meta = False + fetcher.prefetch_data = False + + return fetcher + + def test_complete_fetch(self, fetcher): + """ + Tests stories can be successfully fetched. + """ + story = fetcher.fetch(BOTH_KEY) + meta = self.make_meta(BOTH_KEY) + data = self.make_data(BOTH_KEY) + + assert meta == story.meta + assert data == story.data + + def test_partial_meta_fetch(self, fetcher): + """ + Tests stories with only meta can be partially fetched. + """ + story = fetcher.fetch(META_KEY) + meta = self.make_meta(META_KEY) + + assert meta == story.meta + + with pytest.raises(InvalidStoryError): + story.data + + def test_partial_data_fetch(self, fetcher): + """ + Tests stories with only data can be partially fetched. + """ + story = fetcher.fetch(DATA_KEY) + data = self.make_data(DATA_KEY) + + assert data == story.data + + with pytest.raises(InvalidStoryError): + story.meta + + def test_missing_fetch(self, fetcher): + """ + Tests `InvalidStoryError` is raised for missing stories. + """ + story = fetcher.fetch(NONE_KEY) + + with pytest.raises(InvalidStoryError): + story.meta + + with pytest.raises(InvalidStoryError): + story.data + + @pytest.mark.parametrize('key', (NONE_KEY, META_KEY, DATA_KEY, BOTH_KEY)) + def test_flavors(self, fetcher, flavor, key): + """ + Tests flavors are added to stories. + """ + story = fetcher.fetch(key) + assert {flavor} == set(story.flavors)