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