Add update command

This commit is contained in:
Joakim Soderlund 2017-10-15 01:52:52 +02:00
parent fe1ae39ca4
commit 87d6fe0052
5 changed files with 361 additions and 1 deletions

View file

@ -24,9 +24,11 @@ Command module.
from .base import Command from .base import Command
from .root import RootCommand from .root import RootCommand
from .update import UpdateCommand
__all__ = ( __all__ = (
'Command', 'Command',
'RootCommand', 'RootCommand',
'UpdateCommand',
) )

View file

@ -25,6 +25,7 @@ Root command.
from typing import Dict, Type from typing import Dict, Type
from .base import Command from .base import Command
from .update import UpdateCommand
__all__ = ( __all__ = (
@ -36,7 +37,9 @@ class RootCommand(Command):
""" """
The main application command. The main application command.
""" """
commands: Dict[str, Type[Command]] = dict() commands: Dict[str, Type[Command]] = {
'update': UpdateCommand,
}
def load(self, command: str) -> Command: def load(self, command: str) -> Command:
""" """

View file

@ -0,0 +1,218 @@
"""
Update command.
"""
#
# 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/>.
#
import traceback
from argparse import ArgumentParser, FileType
from typing import Any, Iterable, Iterator, Optional
from jmespath import search
from fimfarchive.fetchers import FimfarchiveFetcher, FimfictionFetcher
from fimfarchive.flavors import UpdateStatus
from fimfarchive.signals import SignalReceiver
from fimfarchive.stories import Story
from fimfarchive.tasks import UpdateTask
from .base import Command
__all__ = (
'UpdateCommand',
)
class StoryFormatter(Iterable[str]):
"""
Generates a text representation of story meta.
"""
attrs = (
'title',
'author',
'status',
'words',
'likes',
'dislikes',
'approval',
'chapters',
'action',
)
def __init__(self, story: Story) -> None:
"""
Constructor.
Args:
story: Instance to represent.
"""
self.story = story
def __getattr__(self, key: str) -> Any:
"""
Returns a value from story meta, or None.
"""
meta = self.story.meta
return meta.get(key)
def __iter__(self) -> Iterator[str]:
"""
Yields the text representation line by line.
"""
for attr in self.attrs:
label = attr.capitalize()
value = getattr(self, attr)
yield f"{label}: {value}"
def __str__(self) -> str:
"""
Returns the entire text representation.
"""
return '\n'.join(self)
@property
def author(self) -> Optional[str]:
"""
Returns the name of the author, or None.
"""
meta = self.story.meta
return search('author.name', meta)
@property
def approval(self) -> Optional[str]:
"""
Returns the likes to dislikes ratio, or None.
"""
meta = self.story.meta
likes = meta.get('likes')
dislikes = meta.get('dislikes')
try:
ratio = likes / (likes + dislikes)
except TypeError:
return None
except ZeroDivisionError:
return f"{0:.0%}"
else:
return f"{ratio:.0%}"
@property
def chapters(self) -> Optional[int]:
"""
Returns the number of chapters, or None.
"""
meta = self.story.meta
chapters = meta.get('chapters')
try:
return len(chapters)
except TypeError:
return None
@property
def action(self) -> Optional[str]:
"""
Returns the `UpdateStatus` name, or None.
"""
for flavor in self.story.flavors:
if isinstance(flavor, UpdateStatus):
return flavor.name.capitalize()
return None
class UpdatePrinter(SignalReceiver):
"""
Prints story information.
"""
def on_attempt(self, sender, key, skips, retries):
"""
Shows an upcoming fetch attempt.
"""
print(f"\nStory: {key}")
if retries:
print(f"Retries: {retries}")
else:
print(f"Skips: {skips}")
def on_success(self, sender, key, story):
"""
Shows the story from a successful fetch.
"""
print(StoryFormatter(story))
def on_skipped(self, sender, key, story):
"""
Shows information from a skipped fetch.
"""
if story:
print(StoryFormatter(story))
else:
print("Status: Missing")
def on_failure(self, sender, key, error):
"""
Shows the exception from a failed fetch.
"""
print("Error:", error)
traceback.print_exc()
class UpdateCommand(Command):
"""
Fetches updates from Fimfiction.
"""
@property
def parser(self) -> ArgumentParser:
"""
Returns a command line arguments parser.
"""
parser = ArgumentParser(
prog='',
description=self.__doc__,
)
parser.add_argument(
'--archive',
help="previous version of the archive",
type=FileType('rb'),
required=True,
metavar='PATH',
)
return parser
def __call__(self, *args):
opts = self.parser.parse_args(args)
task = UpdateTask(
fimfarchive=FimfarchiveFetcher(opts.archive),
fimfiction=FimfictionFetcher(),
)
with UpdatePrinter(task):
task.run()
return 0

View file

@ -1,2 +1,3 @@
blinker blinker
jmespath
requests requests

View file

@ -0,0 +1,136 @@
"""
Update command 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 textwrap import dedent
from typing import Any, Dict, Set
from fimfarchive.flavors import Flavor, MetaPurity, UpdateStatus
from fimfarchive.commands.update import StoryFormatter
class TestStoryFormatter():
"""
StoryFormatter tests.
"""
def assert_formatted_equals(self, expected, story):
"""
Asserts that the formatted story matches the expected text.
"""
formatted = str(StoryFormatter(story))
dedented = dedent(expected).strip()
assert dedented == formatted
def test_empty_meta(self, story):
"""
Tests story formatting with empty meta.
"""
flavors: Set[Flavor] = set()
meta: Dict[str, Any] = dict()
expected = """
Title: None
Author: None
Status: None
Words: None
Likes: None
Dislikes: None
Approval: None
Chapters: None
Action: None
"""
story = story.merge(meta=meta, flavors=flavors)
self.assert_formatted_equals(expected, story)
def test_complete_meta(self, story):
"""
Tests story formatting with complete meta.
"""
flavors: Set[Flavor] = {
UpdateStatus.CREATED,
}
meta: Dict[str, Any] = {
'title': 'A',
'author': {
'name': 'B'
},
'status': 'C',
'words': 4,
'likes': 3,
'dislikes': 2,
'chapters': [
1
],
}
expected = """
Title: A
Author: B
Status: C
Words: 4
Likes: 3
Dislikes: 2
Approval: 60%
Chapters: 1
Action: Created
"""
story = story.merge(meta=meta, flavors=flavors)
self.assert_formatted_equals(expected, story)
def test_edge_meta(self, story):
"""
Tests story formatting with some edge cases.
"""
flavors: Set[Flavor] = {
MetaPurity.DIRTY,
}
meta: Dict[str, Any] = {
'title': None,
'author': {},
'status': {},
'words': 0,
'likes': 0,
'dislikes': 0,
'chapters': (),
}
expected = """
Title: None
Author: None
Status: {}
Words: 0
Likes: 0
Dislikes: 0
Approval: 0%
Chapters: 0
Action: None
"""
story = story.merge(meta=meta, flavors=flavors)
self.assert_formatted_equals(expected, story)