Add selectors module

This commit is contained in:
Joakim Soderlund 2017-01-21 23:37:19 +01:00
parent 8cf81a14a2
commit ce133355ad
3 changed files with 445 additions and 0 deletions

View file

@ -30,6 +30,7 @@ __all__ = (
'StorySource', 'StorySource',
'DataFormat', 'DataFormat',
'MetaPurity', 'MetaPurity',
'UpdateStatus',
) )
@ -80,3 +81,13 @@ class MetaPurity(Flavor):
""" """
CLEAN = () CLEAN = ()
DIRTY = () DIRTY = ()
class UpdateStatus(Flavor):
"""
Indicates if and how a story has changed.
"""
CREATED = ()
REVIVED = ()
UPDATED = ()
DELETED = ()

150
fimfarchive/selectors.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
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

284
tests/test_selectors.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
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