From 87d6fe0052b157af81c04bd887d54762f9d22f80 Mon Sep 17 00:00:00 2001 From: Joakim Soderlund Date: Sun, 15 Oct 2017 01:52:52 +0200 Subject: [PATCH] Add update command --- fimfarchive/commands/__init__.py | 2 + fimfarchive/commands/root.py | 5 +- fimfarchive/commands/update.py | 218 +++++++++++++++++++++++++++++++ requirements.txt | 1 + tests/commands/test_update.py | 136 +++++++++++++++++++ 5 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 fimfarchive/commands/update.py create mode 100644 tests/commands/test_update.py diff --git a/fimfarchive/commands/__init__.py b/fimfarchive/commands/__init__.py index 96631e0..00ccc3c 100644 --- a/fimfarchive/commands/__init__.py +++ b/fimfarchive/commands/__init__.py @@ -24,9 +24,11 @@ Command module. from .base import Command from .root import RootCommand +from .update import UpdateCommand __all__ = ( 'Command', 'RootCommand', + 'UpdateCommand', ) diff --git a/fimfarchive/commands/root.py b/fimfarchive/commands/root.py index 3f6611d..b5adcc9 100644 --- a/fimfarchive/commands/root.py +++ b/fimfarchive/commands/root.py @@ -25,6 +25,7 @@ Root command. from typing import Dict, Type from .base import Command +from .update import UpdateCommand __all__ = ( @@ -36,7 +37,9 @@ class RootCommand(Command): """ The main application command. """ - commands: Dict[str, Type[Command]] = dict() + commands: Dict[str, Type[Command]] = { + 'update': UpdateCommand, + } def load(self, command: str) -> Command: """ diff --git a/fimfarchive/commands/update.py b/fimfarchive/commands/update.py new file mode 100644 index 0000000..db20dfc --- /dev/null +++ b/fimfarchive/commands/update.py @@ -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 . +# + + +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 diff --git a/requirements.txt b/requirements.txt index d9bc224..14f5624 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ blinker +jmespath requests diff --git a/tests/commands/test_update.py b/tests/commands/test_update.py new file mode 100644 index 0000000..931b8b0 --- /dev/null +++ b/tests/commands/test_update.py @@ -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 . +# + + +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)