mirror of
https://github.com/JockeTF/fimfarchive.git
synced 2025-02-08 14:56:44 +01:00
Add selectors module
This commit is contained in:
parent
8cf81a14a2
commit
ce133355ad
3 changed files with 445 additions and 0 deletions
|
@ -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 = ()
|
||||
|
|
150
fimfarchive/selectors.py
Normal file
150
fimfarchive/selectors.py
Normal 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
284
tests/test_selectors.py
Normal 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
|
Loading…
Reference in a new issue