diff --git a/fimfarchive/flavors.py b/fimfarchive/flavors.py index e878160..135de87 100644 --- a/fimfarchive/flavors.py +++ b/fimfarchive/flavors.py @@ -30,6 +30,7 @@ __all__ = ( 'StorySource', 'DataFormat', 'MetaPurity', + 'UpdateStatus', ) @@ -80,3 +81,13 @@ class MetaPurity(Flavor): """ CLEAN = () DIRTY = () + + +class UpdateStatus(Flavor): + """ + Indicates if and how a story has changed. + """ + CREATED = () + REVIVED = () + UPDATED = () + DELETED = () diff --git a/fimfarchive/selectors.py b/fimfarchive/selectors.py new file mode 100644 index 0000000..84082fd --- /dev/null +++ b/fimfarchive/selectors.py @@ -0,0 +1,150 @@ +""" +Selectors 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 . +# + + +from fimfarchive.exceptions import InvalidStoryError +from fimfarchive.flavors import UpdateStatus +from fimfarchive.mappers import StoryDateMapper + + +__all__ = ( + 'Selector', + 'UpdateSelector', +) + + +class Selector: + """ + Picks one of the two supplied stories. + """ + + def __call__(old, new): + """ + Returns either the old or the new story. + + Args: + old: The currently available story. + new: The potential replacement story. + + Returns: + Story: One of the two story objects. + """ + raise NotImplementedError() + + +class UpdateSelector(Selector): + """ + Selects the new story if it needs to be updated. + + An `UpdateStatus` flavor is applied to all returned stories. The + returned story will also be fully fetched. The selector will not + attempt to fetch data from new stories unless they have changed. + """ + + def __init__(self, date_mapper=None): + """ + Constructor. + + Args: + date_mapper: Maps a `Story` to its modification date. + """ + if date_mapper: + self.date_mapper = date_mapper + else: + self.date_mapper = StoryDateMapper() + + def filter_empty(self, story): + """ + Returns the story if it has chapters, otherwise None. + """ + meta = getattr(story, 'meta', None) + + if meta and meta.get('chapters'): + return story + else: + return None + + def filter_invalid(self, story): + """ + Returns the story if it is valid, otherwise None. + """ + try: + story.meta + story.data + except InvalidStoryError: + return None + else: + return story + + def filter_unchanged(self, old, new): + """ + Returns the new story if it has changed, otherwise None. + + Raises: + ValueError: If `date_mapper` returns `None`. + """ + old_date = self.date_mapper(old) + new_date = self.date_mapper(new) + + if old_date is None: + raise ValueError("Missing old date.") + elif new_date is None: + raise ValueError("Missing new date.") + elif old_date < new_date: + return new + else: + return None + + def flavored(self, story, *flavors): + """ + Returns the story after applying the specified flavors. + """ + story.flavors.update(flavors) + + return story + + def __call__(self, old, new): + old = self.filter_empty(old) + new = self.filter_empty(new) + deleted = old and not new + + if old and new: + old = self.filter_invalid(old) + + if old and new: + new = self.filter_unchanged(old, new) + + if old and new: + new = self.filter_invalid(new) + deleted = old and not new + + if not old and new: + return self.flavored(new, UpdateStatus.CREATED) + elif old and not new and not deleted: + return self.flavored(old, UpdateStatus.REVIVED) + elif old and new: + return self.flavored(new, UpdateStatus.UPDATED) + elif old and not new and deleted: + return self.flavored(old, UpdateStatus.DELETED) + else: + return None diff --git a/tests/test_selectors.py b/tests/test_selectors.py new file mode 100644 index 0000000..8244e86 --- /dev/null +++ b/tests/test_selectors.py @@ -0,0 +1,284 @@ +""" +Selector 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 . +# + + +from unittest.mock import patch, Mock, PropertyMock + +import pytest + +from fimfarchive.exceptions import InvalidStoryError +from fimfarchive.fetchers import Fetcher +from fimfarchive.flavors import UpdateStatus +from fimfarchive.mappers import StoryDateMapper +from fimfarchive.selectors import UpdateSelector +from fimfarchive.stories import Story + + +class TestUpdateSelector: + """ + UpdateSelector tests. + """ + + @pytest.fixture + def selector(self): + """ + Returns a new update selector. + """ + return UpdateSelector() + + def merge(self, story, **params): + """ + Returns a cloned story, optionally overriding parameters. + """ + data = vars(story) + data = {k.lstrip('_'): v for k, v in data.items()} + data.update(params) + + return Story(**data) + + def populate(self, story, date=0): + """ + Returns a cloned story populated with chapter meta. + """ + meta = { + **story.meta, + 'date_modified': date, + 'chapters': [ + {'id': 1}, + {'id': 2}, + {'id': 3}, + ], + } + + return self.merge(story, meta=meta) + + def test_filter_empty_with_chapters(self, selector, story): + """ + Tests `filter_empty` keeps stories containing chapter meta. + """ + story = self.populate(story, 1) + selected = selector.filter_empty(story) + + assert selected is story + + def test_filter_empty_without_chapters(self, selector, story): + """ + Tests `filter_empty` drops stories without chapter meta. + """ + selected = selector.filter_empty(story) + + assert selected is None + + def test_filter_empty_with_empty_chapters(self, selector, story): + """ + Tests `filter_empty` drops stories with empty chapter meta. + """ + meta = { + **story.meta, + 'chapters': [] + } + + story = self.merge(story, meta=meta) + selected = selector.filter_empty(story) + + assert selected is None + + def test_filter_invalid_for_valid(self, selector, story): + """ + Tests `filter_invalid` keeps valid stories. + """ + selected = selector.filter_invalid(story) + + assert selected is story + + def test_filter_invalid_without_meta(self, selector, story): + """ + Tests `filter_invalid` drops stories raising invalid on meta. + """ + with patch.object(Story, 'meta', new_callable=PropertyMock) as m: + m.side_effect = InvalidStoryError + selected = selector.filter_invalid(story) + + assert selected is None + + def test_filter_invalid_without_data(self, selector, story): + """ + Tests `filter_invalid` drops stories raising invalid on data. + """ + with patch.object(Story, 'data', new_callable=PropertyMock) as m: + m.side_effect = InvalidStoryError + selected = selector.filter_invalid(story) + + assert selected is None + + def test_filter_unchanged_for_changed(self, selector, story): + """ + Tests `filter_unchanged` keeps changed stories. + """ + old = self.populate(story, 0) + new = self.populate(story, 1) + + selected = selector.filter_unchanged(old, new) + + assert selected is new + + def test_filter_unchanged_for_unchanged(self, selector, story): + """ + Tests `filter_unchanged` drops changed stories. + """ + old = self.populate(story, 0) + new = self.populate(story, 0) + + selected = selector.filter_unchanged(old, new) + + assert selected is None + + def test_filter_unchanged_default_mapper(self): + """ + Tests `date_mapper` defaults to `StoryDateMapper`. + """ + selector = UpdateSelector() + + assert isinstance(selector.date_mapper, StoryDateMapper) + + def test_filter_unchanged_custom_mapper(self, story): + """ + Tests `date_mapper` can be customised. + """ + old = self.populate(story, 1) + new = self.populate(story, 0) + + data = { + old: 0, + new: 1, + } + + date_mapper = data.get + selector = UpdateSelector(date_mapper=date_mapper) + selected = selector.filter_unchanged(old, new) + + assert selector.date_mapper is date_mapper + assert selected is new + + def test_filter_unchanged_missing_old_date(self, selector, story): + """ + Tests `filter_unchanged` raises `ValueError` on missing old date. + """ + old = self.populate(story, None) + new = self.populate(story, 1) + + with pytest.raises(ValueError): + selector.filter_unchanged(old, new) + + def test_filter_unchanged_missing_new_date(self, selector, story): + """ + Tests `filter_unchanged` raises `ValueError` on missing new date. + """ + old = self.populate(story, 1) + new = self.populate(story, None) + + with pytest.raises(ValueError): + selector.filter_unchanged(old, new) + + def test_flavored(self, selector, story): + """ + Tests `flavored` adds specified flavor and returns the story. + """ + assert UpdateStatus.CREATED not in story.flavors + + flavored = selector.flavored(story, UpdateStatus.CREATED) + + assert story is flavored + assert UpdateStatus.CREATED in story.flavors + + def test_created_selection(self, selector, story): + """ + Tests behavior for newly created stories. + """ + old = None + new = self.populate(story, 1) + + selected = selector(old, new) + + assert selected is new + assert UpdateStatus.CREATED in new.flavors + + def test_revived_selection(self, selector, story): + """ + Tests behavior for stories that have not updated. + """ + old = self.populate(story, 1) + new = self.populate(story, 1) + + selected = selector(old, new) + + assert selected is old + assert UpdateStatus.REVIVED in old.flavors + + def test_updated_selection(self, selector, story): + """ + Tests behavior for stories that have updated. + """ + old = self.populate(story, 0) + new = self.populate(story, 1) + + selected = selector(old, new) + + assert selected is new + assert UpdateStatus.UPDATED in new.flavors + + def test_deleted_selection(self, selector, story): + """ + Tests behavior for stories that have been deleted. + """ + old = self.populate(story, 1) + new = None + + selected = selector(old, new) + + assert selected is old + assert UpdateStatus.DELETED in old.flavors + + def test_protected_selection(self, selector, story): + """ + Tests behavior for stories that have become password protected. + """ + old = self.populate(story, 0) + new = self.populate(story, 1) + + fetcher = Mock(spec=Fetcher) + fetcher.fetch_data.side_effect = InvalidStoryError + new = self.merge(story, fetcher=fetcher, data=None) + + selected = selector(old, new) + + assert selected is old + assert UpdateStatus.DELETED in old.flavors + + def test_invalid_selection(self, selector): + """ + Tests behavior for invalid stories. + """ + selected = selector(None, None) + + assert selected is None