From 8c2bb0ecc231f51e5543a92c7b17f1bae1eb6e26 Mon Sep 17 00:00:00 2001 From: Aleksandra Khorosheva Date: Tue, 28 Jun 2022 13:35:29 +0300 Subject: [PATCH 1/4] Create entry point for command line rss parser Parse all required arguments and store in the custom object Params Create parser hierarchy and implement parsing rss from URL Sort and apply limits to rss feed Create processors hierarchy and implement both plain text printer and json printer For verbose mode create message consumer abstraction Wrap main method with try except not to give exceptions for final user --- .gitignore | 4 + rss_parse/__init__.py | 1 + rss_parse/exceptions/__init__.py | 0 rss_parse/exceptions/exceptions.py | 2 + rss_parse/parse/__init__.py | 0 rss_parse/parse/params.py | 14 +++ rss_parse/parse/rss_feed.py | 21 ++++ rss_parse/parse/rss_feed_mapper.py | 61 ++++++++++ rss_parse/parse/rss_keys.py | 13 +++ rss_parse/parse/rss_parser.py | 113 +++++++++++++++++++ rss_parse/parse/rss_parser_factory.py | 6 + rss_parse/processor/__init__.py | 0 rss_parse/processor/rss_processor.py | 68 +++++++++++ rss_parse/processor/rss_processor_factory.py | 10 ++ rss_parse/rss_reader.py | 79 +++++++++++++ rss_parse/utils/__init__.py | 0 rss_parse/utils/formatting_utils.py | 26 +++++ rss_parse/utils/message_consumer.py | 29 +++++ rss_parse/utils/parsing_utils.py | 15 +++ rss_parse/utils/printing_utils.py | 5 + 20 files changed, 467 insertions(+) create mode 100644 .gitignore create mode 100644 rss_parse/__init__.py create mode 100644 rss_parse/exceptions/__init__.py create mode 100644 rss_parse/exceptions/exceptions.py create mode 100644 rss_parse/parse/__init__.py create mode 100644 rss_parse/parse/params.py create mode 100644 rss_parse/parse/rss_feed.py create mode 100644 rss_parse/parse/rss_feed_mapper.py create mode 100644 rss_parse/parse/rss_keys.py create mode 100644 rss_parse/parse/rss_parser.py create mode 100644 rss_parse/parse/rss_parser_factory.py create mode 100644 rss_parse/processor/__init__.py create mode 100644 rss_parse/processor/rss_processor.py create mode 100644 rss_parse/processor/rss_processor_factory.py create mode 100644 rss_parse/rss_reader.py create mode 100644 rss_parse/utils/__init__.py create mode 100644 rss_parse/utils/formatting_utils.py create mode 100644 rss_parse/utils/message_consumer.py create mode 100644 rss_parse/utils/parsing_utils.py create mode 100644 rss_parse/utils/printing_utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..71711469 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +results +venv +.idea +rss_reader.egg-info diff --git a/rss_parse/__init__.py b/rss_parse/__init__.py new file mode 100644 index 00000000..a4e2017f --- /dev/null +++ b/rss_parse/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1" diff --git a/rss_parse/exceptions/__init__.py b/rss_parse/exceptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rss_parse/exceptions/exceptions.py b/rss_parse/exceptions/exceptions.py new file mode 100644 index 00000000..aec9c9a9 --- /dev/null +++ b/rss_parse/exceptions/exceptions.py @@ -0,0 +1,2 @@ +class ParsingException(Exception): + pass diff --git a/rss_parse/parse/__init__.py b/rss_parse/parse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rss_parse/parse/params.py b/rss_parse/parse/params.py new file mode 100644 index 00000000..6aad8b8d --- /dev/null +++ b/rss_parse/parse/params.py @@ -0,0 +1,14 @@ +class Params: + """ + Stores parameters to run rss reader. + """ + + def __init__(self, is_verbose, is_json, limit, source): + self.is_verbose = is_verbose + self.is_json = is_json + self.limit = limit + self.source = source + + @staticmethod + def from_args(args): + return Params(args.verbose, args.json, args.limit, args.source) diff --git a/rss_parse/parse/rss_feed.py b/rss_parse/parse/rss_feed.py new file mode 100644 index 00000000..33974f9d --- /dev/null +++ b/rss_parse/parse/rss_feed.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import List + + +@dataclass +class RssItem: + title: str + description: str + publication_date: datetime + link: str + image_url: str + source: str = None + + def key(self): + return self.link, self.publication_date + + +@dataclass +class RssFeed: + rss_items: List[RssItem] diff --git a/rss_parse/parse/rss_feed_mapper.py b/rss_parse/parse/rss_feed_mapper.py new file mode 100644 index 00000000..97e68a76 --- /dev/null +++ b/rss_parse/parse/rss_feed_mapper.py @@ -0,0 +1,61 @@ +import json +from datetime import timezone, datetime + +from rss_parse.parse.rss_feed import RssFeed, RssItem +from rss_parse.parse.rss_keys import * +from rss_parse.utils.formatting_utils import format_date_pretty, get_description_plain + + +class RssFeedJsonMapper: + __DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" + + def to_json(self, rss_feed: RssFeed, indent=0, pretty=False): + res = { + RSS_ITEMS: [self.__item_to_json(item, pretty) for item in rss_feed.rss_items] + } + + return json.dumps(res, indent=indent) + + def __item_to_json(self, item: RssItem, pretty): + res = { + RSS_ITEM_TITLE: item.title, + RSS_ITEM_LINK: item.link, + } + # Store as UTC + publication_date = item.publication_date.astimezone(timezone.utc) \ + .strftime(RssFeedJsonMapper.__DATE_TIME_FORMAT) + description = item.description + if pretty: + publication_date = format_date_pretty(item.publication_date) + description = get_description_plain(description) + + res[RSS_ITEM_PUB_DATE] = publication_date + + if description: + res[RSS_ITEM_DESCRIPTION] = description + + if item.image_url: + res[RSS_IMAGE_ROOT] = item.image_url + + if item.source: + res[RSS_SOURCE] = item.source + + return res + + def from_json(self, rss_feed_json): + rss_dict = json.loads(rss_feed_json) + items = [self.__parse_item(item) for item in rss_dict[RSS_ITEMS]] + return RssFeed(items) + + def __parse_item(self, item): + title = item[RSS_ITEM_TITLE] + description = item.get(RSS_ITEM_DESCRIPTION, None) + publication_date = datetime.strptime(item[RSS_ITEM_PUB_DATE], RssFeedJsonMapper.__DATE_TIME_FORMAT) \ + .replace(tzinfo=timezone.utc).astimezone(timezone.utc) + link = item[RSS_ITEM_LINK] + image_url = item.get(RSS_IMAGE_ROOT, None) + source = item.get(RSS_SOURCE, None) + return RssItem(title, description, publication_date, link, image_url, source) + + +RSS_FEED_JSON_MAPPER = RssFeedJsonMapper() diff --git a/rss_parse/parse/rss_keys.py b/rss_parse/parse/rss_keys.py new file mode 100644 index 00000000..5566a8e9 --- /dev/null +++ b/rss_parse/parse/rss_keys.py @@ -0,0 +1,13 @@ +RSS_ROOT = 'rss' +RSS_CHANNEL = 'channel' +RSS_ITEMS = 'item' +RSS_ITEM_TITLE = 'title' +RSS_ITEM_DESCRIPTION = 'description' +RSS_ITEM_LINK = 'link' +RSS_ITEM_PUB_DATE = 'pubDate' +RSS_IMAGE_ROOT = 'image' +RSS_IMAGE_ROOT_ENCLOSURE = 'enclosure' +RSS_IMAGE_ROOT_MEDIA_CONTENT = 'media:content' +RSS_IMAGE_ROOT_MEDIA_THUMBNAIL = 'media:thumbnail' +RSS_IMAGE_URL_ATTR = '@url' +RSS_SOURCE = 'source' diff --git a/rss_parse/parse/rss_parser.py b/rss_parse/parse/rss_parser.py new file mode 100644 index 00000000..71883a95 --- /dev/null +++ b/rss_parse/parse/rss_parser.py @@ -0,0 +1,113 @@ +import xml +from abc import ABC, abstractmethod + +import requests +import xmltodict +from requests.exceptions import InvalidSchema, InvalidURL, MissingSchema + +from rss_parse.exceptions.exceptions import ParsingException +from rss_parse.parse.rss_feed import RssFeed, RssItem +from rss_parse.parse.rss_keys import * +from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP +from rss_parse.utils.parsing_utils import sanitize_text, to_date + + +class RssParser(ABC): + + def __init__(self, mc=MESSAGE_CONSUMER_NOOP): + self._mc = mc + + @abstractmethod + def parse(self): + """ + :rtype: rss_parse.rss_feed.RssFeed + """ + pass + + +class RssXmlParser(RssParser): + + def __init__(self, xml_feed, mc=None): + super().__init__(mc) + self.__xml_feed = xml_feed + self._mc = mc + + def parse(self): + + self._mc.add_message("Parsing RSS Feed by elements") + try: + rss_feed_dict = xmltodict.parse(self.__xml_feed)[RSS_ROOT] + except (xml.parsers.expat.ExpatError, KeyError): + # TODO: include original exception to the + raise ParsingException("Source doesn't contain a valid RSS Feed.") + + self._mc.add_message("Parsing items info") + rss_items = self.parse_items(rss_feed_dict) + + self._mc.add_message("Parsing finished") + return RssFeed(rss_items) + + def parse_items(self, rss_feed_dict): + rss_items_raw = rss_feed_dict[RSS_CHANNEL][RSS_ITEMS] + res = [] + for rss_item_dict in rss_items_raw: + item = self.parse_item(rss_item_dict) + if self.__validate_correctness(item): + res.append(item) + else: + self._mc.add_message("Item skipped because it is invalid (required fields are absent)") + return res + + def parse_item(self, rss_item_dict): + title = sanitize_text(rss_item_dict.get(RSS_ITEM_TITLE, None)) + description = rss_item_dict.get(RSS_ITEM_DESCRIPTION, None) + publication_date = to_date(rss_item_dict.get(RSS_ITEM_PUB_DATE, None)) + link = rss_item_dict.get(RSS_ITEM_LINK, None) + image_url = self.parse_image(rss_item_dict) + + return RssItem(title, description, publication_date, link, image_url) + + def parse_image(self, rss_item_dict): + image_url = rss_item_dict.get(RSS_IMAGE_ROOT, None) + if not image_url: + image_url = rss_item_dict.get(RSS_IMAGE_ROOT_MEDIA_CONTENT, {}).get(RSS_IMAGE_URL_ATTR, None) + if not image_url: + image_url = rss_item_dict.get(RSS_IMAGE_ROOT_MEDIA_THUMBNAIL, {}).get(RSS_IMAGE_URL_ATTR, None) + if not image_url: + enclosure = rss_item_dict.get(RSS_IMAGE_ROOT_ENCLOSURE, {}) + if enclosure.get('@type', "").startswith("image/"): + image_url = enclosure.get(RSS_IMAGE_URL_ATTR, None) + return image_url + + def __validate_correctness(self, item: RssItem): + return item.title and item.publication_date and item.link + + +class RssUrlParser(RssParser): + + def __init__(self, source, mc=None): + # FIXME: assert source is not None, "Source URL is required" + super().__init__(mc) + self.__source = source + self._mc = mc + + def parse(self): + try: + self._mc.add_message(f"Reaching out to {self.__source}") + with requests.get(self.__source) as f: + # TODO: add error handling + rss_raw_xml = f.text + except (InvalidSchema, InvalidURL, MissingSchema): + self._mc.add_message(f"Encountered an error during reading RSS Feed from URL") + raise ParsingException(f"Invalid source URL: {self.__source}") + except: # ConnectionError + self._mc.add_message(f"Unable to connect") + raise ParsingException(f"Unable to connect to {self.__source}") + + rss_xml_parser = RssXmlParser(rss_raw_xml, mc=self._mc) + + feed = rss_xml_parser.parse() + for item in feed.rss_items: + item.source = self.__source + + return feed diff --git a/rss_parse/parse/rss_parser_factory.py b/rss_parse/parse/rss_parser_factory.py new file mode 100644 index 00000000..af2319f4 --- /dev/null +++ b/rss_parse/parse/rss_parser_factory.py @@ -0,0 +1,6 @@ +from rss_parse.parse.rss_parser import RssUrlParser +from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP + + +def get_parser(source, mc=MESSAGE_CONSUMER_NOOP): + return RssUrlParser(source, mc=mc) diff --git a/rss_parse/processor/__init__.py b/rss_parse/processor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rss_parse/processor/rss_processor.py b/rss_parse/processor/rss_processor.py new file mode 100644 index 00000000..40db8fc9 --- /dev/null +++ b/rss_parse/processor/rss_processor.py @@ -0,0 +1,68 @@ +from abc import ABC, abstractmethod + +from rss_parse.parse.rss_feed import RssFeed, RssItem +from rss_parse.parse.rss_feed_mapper import RSS_FEED_JSON_MAPPER +from rss_parse.utils.formatting_utils import format_date_pretty, get_description_plain +from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP + + +class RssProcessor(ABC): + + def __init__(self, rss_feed, mc=MESSAGE_CONSUMER_NOOP): + self.rss_feed = rss_feed + self._mc = mc + + @abstractmethod + def process(self): + pass + + +class RssPrinter(RssProcessor): + __SEPARATOR = "----------" + + def __init__(self, rss_feed: RssFeed, file_descriptor, mc=None): + super().__init__(rss_feed, mc) + self.file_descriptor = file_descriptor + + def process(self): + self._mc.add_message("Staring to print the feed in a human-readable format") + self.__print() + self.print_items_info() + self._mc.add_message("Finishing printing") + + def print_items_info(self): + rss_items = self.rss_feed.rss_items + for item in rss_items: + self.print_item_info(item) + self.__print() + self.__print(RssPrinter.__SEPARATOR) + self.__print() + + def print_item_info(self, item: RssItem): + self.__print(f"Title: {item.title}") + self.__print(f"Date: {format_date_pretty(item.publication_date)}") + self.__print(f"Link: {item.link}") + + image_url = item.image_url + if image_url: + image_title = item.title + self.__print(f"Image: [{image_title}]({image_url})]") + + if item.description: + self.__print() + self.__print(get_description_plain(item.description)) + + def __print(self, v="", sep=None): + print(v, file=self.file_descriptor, sep=sep) + + +class RssJsonPrinter(RssProcessor): + + def process(self): + self._mc.add_message("Converting to json") + + json_str = RSS_FEED_JSON_MAPPER.to_json(self.rss_feed, indent=4, pretty=True) + + self._mc.add_message("Printing json") + print(json_str) + self._mc.add_message("Printing finished") diff --git a/rss_parse/processor/rss_processor_factory.py b/rss_parse/processor/rss_processor_factory.py new file mode 100644 index 00000000..54dac5c0 --- /dev/null +++ b/rss_parse/processor/rss_processor_factory.py @@ -0,0 +1,10 @@ +import sys + +from rss_parse.processor.rss_processor import RssPrinter, RssJsonPrinter +from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP + + +def get_processor(rss_feed, is_json=False, mc=MESSAGE_CONSUMER_NOOP): + if is_json: + return RssJsonPrinter(rss_feed, mc=mc) + return RssPrinter(rss_feed, sys.stdout, mc=mc) diff --git a/rss_parse/rss_reader.py b/rss_parse/rss_reader.py new file mode 100644 index 00000000..90eed9f1 --- /dev/null +++ b/rss_parse/rss_reader.py @@ -0,0 +1,79 @@ +import argparse +import sys + +from rss_parse import __version__ +from rss_parse.parse.params import Params +from rss_parse.parse.rss_feed import RssFeed +from rss_parse.parse.rss_parser import ParsingException +from rss_parse.parse.rss_parser_factory import get_parser +from rss_parse.processor.rss_processor_factory import get_processor +from rss_parse.utils.message_consumer import get_message_consumer +from rss_parse.utils.printing_utils import print_error + + +def parse_params_from_arguments(): + parser = argparse.ArgumentParser(description="Pure Python command-line RSS reader.") + # FIXME: Doesn't work with parse_args([*]). Look at action + parser.add_argument("source", help="RSS URL", nargs='?' if '--date' in sys.argv else None) + parser.add_argument("--version", help="Print version info", action="version", + version=f"Version {__version__}") + parser.add_argument("--json", help="Print result as JSON in stdout", action="store_true") + parser.add_argument("--verbose", help="Output verbose status messages", action="store_true") + parser.add_argument("--limit", help="Limit news topics if this parameter provided", type=int, default=-1) + args = parser.parse_args() + + return Params.from_args(args) + + +def main(): + """ + Add args, which will be available, when you will work with console. + """ + params = parse_params_from_arguments() + mc = get_message_consumer(params.is_verbose) + + mc.add_message("Program started") + + mc.add_message("Initializing parser...") + parser = get_parser(params.source, mc) + mc.add_message("Parser initialized") + + rss_feed = None + try: + rss_feed = parser.parse() + except ParsingException as ex: + mc.add_message("Encountered an error during parsing") + print_error(str(ex)) + mc.add_message("Exiting the program") + # FIXME: Map Exceptions to errors or something like this + exit(2) + + # Fixme: move to preprocessor + rss_items = sorted(rss_feed.rss_items, key=lambda item: item.publication_date, reverse=True) + if params.limit > 0: + mc.add_message("Applying limit option") + rss_items = rss_items[:params.limit] + rss_feed = RssFeed(rss_items) + + processor = get_processor(rss_feed, params.is_json, mc) + processor.process() + + +# FIXME: REMOVE +import traceback + + +def main_wrapper(): + try: + main() + exit(0) + except Exception as e: + # FIXME: REMOVE IN LATEST VERSION + print(traceback.format_exc()) + print_error("Unknown error, please rerun the application") + # TODO: logging + exit(1) + + +if __name__ == '__main__': + main_wrapper() diff --git a/rss_parse/utils/__init__.py b/rss_parse/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rss_parse/utils/formatting_utils.py b/rss_parse/utils/formatting_utils.py new file mode 100644 index 00000000..35b6a162 --- /dev/null +++ b/rss_parse/utils/formatting_utils.py @@ -0,0 +1,26 @@ +from html2text import HTML2Text + + +def __configure_translator(): + translator = HTML2Text() + translator.inline_links = False + translator.wrap_links = False + return translator + + +HTML_TO_TEXT_TRANSLATOR = __configure_translator() + + +def format_date_pretty(pub_date): + if not pub_date: + return "" + return pub_date.strftime("%a, %d %b %Y %H:%M:%S %z") + + +def get_description_plain(description): + if not description: + return description + desc = description.strip() + # try to parse description as html + html = HTML_TO_TEXT_TRANSLATOR.handle(desc) + return html.rstrip() if html else desc diff --git a/rss_parse/utils/message_consumer.py b/rss_parse/utils/message_consumer.py new file mode 100644 index 00000000..7d9499a8 --- /dev/null +++ b/rss_parse/utils/message_consumer.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod + + +class MessageConsumer(ABC): + + @abstractmethod + def add_message(self, message): + pass + + +class MessageConsumerNoop(MessageConsumer): + + def add_message(self, message): + pass + + +class VerboseMessageConsumer(MessageConsumer): + + def add_message(self, message): + print(f'[[[ {message} ]]]') + + +def get_message_consumer(is_verbose): + if is_verbose: + return VerboseMessageConsumer() + return MESSAGE_CONSUMER_NOOP + + +MESSAGE_CONSUMER_NOOP = MessageConsumerNoop() diff --git a/rss_parse/utils/parsing_utils.py b/rss_parse/utils/parsing_utils.py new file mode 100644 index 00000000..8ad067c5 --- /dev/null +++ b/rss_parse/utils/parsing_utils.py @@ -0,0 +1,15 @@ +from dateutil import parser as iso_date_parser +from dateutil.parser import ParserError + + +def sanitize_text(txt): + return txt.replace(" ", " ") + + +def to_date(date_str): + if not date_str: + return None + try: + return iso_date_parser.parse(date_str).astimezone() + except ParserError: + return None diff --git a/rss_parse/utils/printing_utils.py b/rss_parse/utils/printing_utils.py new file mode 100644 index 00000000..0c2a1f6b --- /dev/null +++ b/rss_parse/utils/printing_utils.py @@ -0,0 +1,5 @@ +import sys + + +def print_error(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) From 2816284d1e920ac0f1c95ee4976a2bfa632e8b67 Mon Sep 17 00:00:00 2001 From: Aleksandra Khorosheva Date: Tue, 28 Jun 2022 13:45:12 +0300 Subject: [PATCH 2/4] Write instruction to set up and run application Write dependencies in setup.py Make requirements.txt fetch dependencies from setup.py --- README.md | 56 ++++++++++++++++++++++--------------------- requirements.txt | 1 + rss_parse/__init__.py | 2 +- setup.py | 42 ++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 28 deletions(-) create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/README.md b/README.md index c86d1e65..43517120 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,30 @@ -# How to create a PR with a homework task - -1. Create fork from the following repo: https://github.com/E-P-T/Homework. (Docs: https://docs.github.com/en/get-started/quickstart/fork-a-repo ) -2. Clone your forked repo in your local folder. -3. Create separate branches for each session.Example(`session_2`, `session_3` and so on) -4. Create folder with you First and Last name in you forked repo in the created session. -5. Add your task into created folder -6. Push finished session task in the appropriate branch in accordance with written above. - You should get the structure that looks something like that - -``` - Branch: Session_2 - DzmitryKolb - |___Task1.py - |___Task2.py - Branch: Session_3 - DzmitryKolb - |___Task1.py - |___Task2.py -``` - -7. When you finish your work on task you should create Pull request to the appropriate branch of the main repo https://github.com/E-P-T/Homework (Docs: https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork). -Please use the following instructions to prepare good description of the pull request: - - Pull request header should be: `Session - `. - Example: `Session 2 - Dzmitry Kolb` - - Pull request body: You should write here what tasks were implemented. - Example: `Finished: Task 1.2, Task 1.3, Task 1.6` +## Documentation +### Minimal requirements: +__Python 3.9__ + +### Setup: +Creating Virtual Environment (from the root of the project)\ +`python -m venv ./venv` + +Activate Virtual Environment:\ +`./venv/Scripts/activate` + +*_On Windows you might need to give rights to execute commands from PowerShell via the following command (running as Administrator)_\ +`Set-ExecutionPolicy Unrestricted` + +*_If you want to exit Virtual Environment please run `deactivate`_ + +Update pip:\ +`python -m pip install --upgrade pip` + +Install requirements:\ +`pip install -r ./requirements.txt` + +### Run Application: +Run `python ./rss_parse/rss_reader.py --help` to find available options + +### Package distributive: +To create a distribution package please run\ +`pip install -e .` +You will be able to run `rss-reader` directly diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..d6e1198b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +-e . diff --git a/rss_parse/__init__.py b/rss_parse/__init__.py index a4e2017f..09888577 100644 --- a/rss_parse/__init__.py +++ b/rss_parse/__init__.py @@ -1 +1 @@ -__version__ = "0.1" +__version__ = "0.2" diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..c20ef47b --- /dev/null +++ b/setup.py @@ -0,0 +1,42 @@ +from setuptools import setup, find_packages + +import re + +VERSION_FILE = 'rss_parse/__init__.py' + + +def version(): + _version_re = re.compile(r'^\s*__version__\s*=\s*[\'"](.*)[\'"]\s*$') + + with open(VERSION_FILE, 'r') as f: + res = _version_re.search(f.read()) + if res is None: + raise RuntimeError(f"Unable to find version string in {VERSION_FILE}.") + ver = res.group(1) + + return ver + + +setup( + name='rss_reader', + version=version(), + description='Pure Python command-line RSS reader.', + author='Aleksandra Khorosheva', + zip_safe=False, + author_email='alexa.horoschewa@gmail.com', + keywords=['RSS Reader', 'RSS Feed Parser'], + install_requires=[ + 'setuptools~=57.0.0', + 'requests~=2.27.1', + 'xmltodict~=0.13.0', + 'python-dateutil~=2.8.0', + 'html2text>=2020.1.16', + ], + python_requires=">=3.9", + packages=find_packages(), + entry_points={ + 'console_scripts': [ + 'rss_reader=rss_parse.rss_reader:main_wrapper', + ], + }, +) From f27a6065a87a37eb174e36f8c92d96b9db883379 Mon Sep 17 00:00:00 2001 From: Aleksandra Khorosheva Date: Tue, 28 Jun 2022 23:42:02 +0300 Subject: [PATCH 3/4] Add date param Implement mapper from and to json Implement local caching. Store cache in temporary file in json format --- rss_parse/__init__.py | 2 +- rss_parse/exceptions/exceptions.py | 4 ++ rss_parse/parse/params.py | 5 +- rss_parse/parse/rss_feed_cache.py | 67 +++++++++++++++++++++++++++ rss_parse/parse/rss_parser.py | 17 +++++++ rss_parse/parse/rss_parser_factory.py | 5 +- rss_parse/rss_reader.py | 14 +++++- rss_parse/utils/arg_parse_types.py | 9 ++++ rss_parse/utils/collection_utils.py | 15 ++++++ 9 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 rss_parse/parse/rss_feed_cache.py create mode 100644 rss_parse/utils/arg_parse_types.py create mode 100644 rss_parse/utils/collection_utils.py diff --git a/rss_parse/__init__.py b/rss_parse/__init__.py index 09888577..6a35e852 100644 --- a/rss_parse/__init__.py +++ b/rss_parse/__init__.py @@ -1 +1 @@ -__version__ = "0.2" +__version__ = "0.3" diff --git a/rss_parse/exceptions/exceptions.py b/rss_parse/exceptions/exceptions.py index aec9c9a9..4c84ba1d 100644 --- a/rss_parse/exceptions/exceptions.py +++ b/rss_parse/exceptions/exceptions.py @@ -1,2 +1,6 @@ class ParsingException(Exception): pass + + +class CacheException(Exception): + pass diff --git a/rss_parse/parse/params.py b/rss_parse/parse/params.py index 6aad8b8d..6efe6292 100644 --- a/rss_parse/parse/params.py +++ b/rss_parse/parse/params.py @@ -3,12 +3,13 @@ class Params: Stores parameters to run rss reader. """ - def __init__(self, is_verbose, is_json, limit, source): + def __init__(self, is_verbose, is_json, limit, source, pub_date): self.is_verbose = is_verbose self.is_json = is_json self.limit = limit self.source = source + self.pub_date = pub_date @staticmethod def from_args(args): - return Params(args.verbose, args.json, args.limit, args.source) + return Params(args.verbose, args.json, args.limit, args.source, args.date) diff --git a/rss_parse/parse/rss_feed_cache.py b/rss_parse/parse/rss_feed_cache.py new file mode 100644 index 00000000..ca44ab79 --- /dev/null +++ b/rss_parse/parse/rss_feed_cache.py @@ -0,0 +1,67 @@ +import os +import tempfile + +from rss_parse.exceptions.exceptions import CacheException, ParsingException +from rss_parse.parse.rss_feed import RssFeed +from rss_parse.parse.rss_feed_mapper import RSS_FEED_JSON_MAPPER +from rss_parse.parse.rss_parser import RssJsonParser +from rss_parse.utils.collection_utils import group_by, merge_by_key +from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP + + +class TmpDirectoryCache: + __DATE_TO_FILE_NAME_PATTERN = '%Y%m%d' + + def __init__(self, rss_feed, mc=MESSAGE_CONSUMER_NOOP): + self.__rss_feed = rss_feed + self.__mc = mc + self.__base_dir = TmpDirectoryCache.get_cache_base_path() + + @staticmethod + def get_cache_base_path(): + return os.path.join(tempfile.gettempdir(), "rss_reader") + + @staticmethod + def get_cache_path(pub_date): + return os.path.join(TmpDirectoryCache.get_cache_base_path(), + f"{pub_date.strftime(TmpDirectoryCache.__DATE_TO_FILE_NAME_PATTERN)}.json") + + def cache(self): + try: + if not os.path.exists(self.__base_dir): + os.mkdir(self.__base_dir) + except: + raise CacheException("Unable to create a directory for local cache") + + if not self.__rss_feed or not self.__rss_feed.rss_items: + return + + feed_by_date = group_by(self.__rss_feed.rss_items, + key=lambda x: x.publication_date.strftime( + TmpDirectoryCache.__DATE_TO_FILE_NAME_PATTERN)) + for pub_date, new_items in feed_by_date: + file_name = os.path.join(self.__base_dir, f"{pub_date}.json") + json_parser = RssJsonParser(file_name, self.__mc) + existing_items = json_parser.parse().rss_items + all_items = merge_by_key([*existing_items, *new_items], key=lambda x: x.key()) + all_feed = RssFeed(all_items) + rss_json = RSS_FEED_JSON_MAPPER.to_json(all_feed) + with open(file_name, "w") as f: + f.write(rss_json) + + +class CacheJsonParser(RssJsonParser): + + def __init__(self, date, source, mc=None): + super().__init__(TmpDirectoryCache.get_cache_path(date), mc) + + self.__source = source + + def parse(self): + rss_feed = super().parse() + items = rss_feed.rss_items + if self.__source: + items = [item for item in items if item.source == self.__source] + if not items: + raise ParsingException("No cached news for the date") + return RssFeed(items) diff --git a/rss_parse/parse/rss_parser.py b/rss_parse/parse/rss_parser.py index 71883a95..61adf242 100644 --- a/rss_parse/parse/rss_parser.py +++ b/rss_parse/parse/rss_parser.py @@ -1,3 +1,4 @@ +import os.path import xml from abc import ABC, abstractmethod @@ -7,6 +8,7 @@ from rss_parse.exceptions.exceptions import ParsingException from rss_parse.parse.rss_feed import RssFeed, RssItem +from rss_parse.parse.rss_feed_mapper import RSS_FEED_JSON_MAPPER from rss_parse.parse.rss_keys import * from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP from rss_parse.utils.parsing_utils import sanitize_text, to_date @@ -25,6 +27,21 @@ def parse(self): pass +class RssJsonParser(RssParser): + + def __init__(self, file_name, mc=None): + super().__init__(mc) + self.__file_name = file_name + self._mc = mc + + def parse(self): + if not os.path.exists(self.__file_name): + return RssFeed([]) + with open(self.__file_name, "r") as f: + rss_json = f.read() + return RSS_FEED_JSON_MAPPER.from_json(rss_json) + + class RssXmlParser(RssParser): def __init__(self, xml_feed, mc=None): diff --git a/rss_parse/parse/rss_parser_factory.py b/rss_parse/parse/rss_parser_factory.py index af2319f4..cbcd544f 100644 --- a/rss_parse/parse/rss_parser_factory.py +++ b/rss_parse/parse/rss_parser_factory.py @@ -1,6 +1,9 @@ +from rss_parse.parse.rss_feed_cache import CacheJsonParser from rss_parse.parse.rss_parser import RssUrlParser from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP -def get_parser(source, mc=MESSAGE_CONSUMER_NOOP): +def get_parser(date, source, mc=MESSAGE_CONSUMER_NOOP): + if date: + return CacheJsonParser(date, source, mc=mc) return RssUrlParser(source, mc=mc) diff --git a/rss_parse/rss_reader.py b/rss_parse/rss_reader.py index 90eed9f1..6d19b3c1 100644 --- a/rss_parse/rss_reader.py +++ b/rss_parse/rss_reader.py @@ -2,11 +2,14 @@ import sys from rss_parse import __version__ +from rss_parse.exceptions.exceptions import CacheException from rss_parse.parse.params import Params from rss_parse.parse.rss_feed import RssFeed +from rss_parse.parse.rss_feed_cache import TmpDirectoryCache from rss_parse.parse.rss_parser import ParsingException from rss_parse.parse.rss_parser_factory import get_parser from rss_parse.processor.rss_processor_factory import get_processor +from rss_parse.utils.arg_parse_types import date_YYYYMMDD from rss_parse.utils.message_consumer import get_message_consumer from rss_parse.utils.printing_utils import print_error @@ -20,6 +23,7 @@ def parse_params_from_arguments(): parser.add_argument("--json", help="Print result as JSON in stdout", action="store_true") parser.add_argument("--verbose", help="Output verbose status messages", action="store_true") parser.add_argument("--limit", help="Limit news topics if this parameter provided", type=int, default=-1) + parser.add_argument("--date", help="Limit the feed by publication date - format YYYYMMDD", type=date_YYYYMMDD) args = parser.parse_args() return Params.from_args(args) @@ -35,7 +39,7 @@ def main(): mc.add_message("Program started") mc.add_message("Initializing parser...") - parser = get_parser(params.source, mc) + parser = get_parser(params.pub_date, params.source, mc) mc.add_message("Parser initialized") rss_feed = None @@ -48,6 +52,14 @@ def main(): # FIXME: Map Exceptions to errors or something like this exit(2) + # FIXME: Ideally I need to skip caching if I took it from cache + mc.add_message("Trying to add fetched news to the local cache") + try: + tmp_cache = TmpDirectoryCache(rss_feed) + tmp_cache.cache() + except CacheException: + mc.add_message("Unable to save RSS Feed to cache. Proceeding...") + # Fixme: move to preprocessor rss_items = sorted(rss_feed.rss_items, key=lambda item: item.publication_date, reverse=True) if params.limit > 0: diff --git a/rss_parse/utils/arg_parse_types.py b/rss_parse/utils/arg_parse_types.py new file mode 100644 index 00000000..912f5eb8 --- /dev/null +++ b/rss_parse/utils/arg_parse_types.py @@ -0,0 +1,9 @@ +import argparse +from datetime import datetime + + +def date_YYYYMMDD(s): + try: + return datetime.strptime(s, "%Y%m%d") + except ValueError: + raise argparse.ArgumentTypeError(f"Not a valid format of date: {s}") diff --git a/rss_parse/utils/collection_utils.py b/rss_parse/utils/collection_utils.py new file mode 100644 index 00000000..a9ebf6f1 --- /dev/null +++ b/rss_parse/utils/collection_utils.py @@ -0,0 +1,15 @@ +from collections import defaultdict + + +def group_by(it, key=lambda x: x): + d = defaultdict(list) + for item in it: + d[key(item)].append(item) + return d.items() + + +def merge_by_key(it, key=lambda x: x): + d = {} + for item in it: + d[key(item)] = item + return list(d.values()) From ecfe99c741380a65b47ca4e91af004ce460a7050 Mon Sep 17 00:00:00 2001 From: Aleksandra Khorosheva Date: Wed, 29 Jun 2022 23:15:50 +0300 Subject: [PATCH 4/4] Add conversion to html and pdf Make html conversion with the plain strings (build html by hand) Make pdf conversion with the help of fpdf library. It requires a font that supports unicode. Add OpenSans.ttf to the project and create a task in setup.py to copy it to a library folder (it is the only consistent way to be able to run the program from any folder on the system) Write unit and integration tests for crucial parts of application (using pytest and mocks) Move sorting, cache and limit to preprocessing Write documentation for caching and testing Update requirements Fix all FIXME and TODOs Reformat the code --- .gitignore | 1 + README.md | 25 +++++-- fonts/OpenSans.ttf | Bin 0 -> 129796 bytes requirements.txt | 2 + rss_parse/__init__.py | 2 +- rss_parse/exceptions/exceptions.py | 13 ++++ rss_parse/parse/params.py | 6 +- .../parse/{rss_feed_cache.py => rss_cache.py} | 22 +++++- rss_parse/parse/rss_feed.py | 7 ++ .../{rss_feed_mapper.py => rss_mapper.py} | 17 +++-- rss_parse/parse/rss_parser.py | 27 ++++--- rss_parse/parse/rss_parser_factory.py | 15 ++-- rss_parse/preprocessor/__init__.py | 0 rss_parse/preprocessor/rss_preprocessor.py | 61 ++++++++++++++++ .../preprocessor/rss_preprocessor_factory.py | 14 ++++ rss_parse/processor/rss_html_converter.py | 50 +++++++++++++ rss_parse/processor/rss_pdf_converter.py | 55 ++++++++++++++ rss_parse/processor/rss_processor.py | 18 ++++- rss_parse/processor/rss_processor_factory.py | 26 +++++-- rss_parse/rss_reader.py | 68 ++++++++---------- rss_parse/utils/arg_parse_types.py | 14 ++++ rss_parse/utils/collection_utils.py | 6 ++ rss_parse/utils/formatting_utils.py | 6 ++ ...message_consumer.py => messaging_utils.py} | 17 +++++ rss_parse/utils/parsing_utils.py | 6 ++ rss_parse/utils/printing_utils.py | 5 -- setup.py | 67 +++++++++++++++-- tests/it/test_it_arg_parse_types.py | 16 +++++ tests/unit/test_collection_utils.py | 37 ++++++++++ tests/unit/test_messaging_utils.py | 13 ++++ tests/unit/test_parsing_utils.py | 26 +++++++ tests/unit/test_rss_feed_mapper.py | 42 +++++++++++ tests/unit/test_rss_feed_preprocessor.py | 41 +++++++++++ 33 files changed, 638 insertions(+), 87 deletions(-) create mode 100644 fonts/OpenSans.ttf rename rss_parse/parse/{rss_feed_cache.py => rss_cache.py} (78%) rename rss_parse/parse/{rss_feed_mapper.py => rss_mapper.py} (80%) create mode 100644 rss_parse/preprocessor/__init__.py create mode 100644 rss_parse/preprocessor/rss_preprocessor.py create mode 100644 rss_parse/preprocessor/rss_preprocessor_factory.py create mode 100644 rss_parse/processor/rss_html_converter.py create mode 100644 rss_parse/processor/rss_pdf_converter.py rename rss_parse/utils/{message_consumer.py => messaging_utils.py} (56%) delete mode 100644 rss_parse/utils/printing_utils.py create mode 100644 tests/it/test_it_arg_parse_types.py create mode 100644 tests/unit/test_collection_utils.py create mode 100644 tests/unit/test_messaging_utils.py create mode 100644 tests/unit/test_parsing_utils.py create mode 100644 tests/unit/test_rss_feed_mapper.py create mode 100644 tests/unit/test_rss_feed_preprocessor.py diff --git a/.gitignore b/.gitignore index 71711469..67a60dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ results venv .idea rss_reader.egg-info +.pytest_cache diff --git a/README.md b/README.md index 43517120..df3d7b97 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,43 @@ ## Documentation ### Minimal requirements: -__Python 3.9__ +__Python 3.9__\ +On linux please add alias _python_ to _python3_. Look [here](https://askubuntu.com/questions/320996/how-to-make-python-program-command-execute-python-3). ### Setup: +#### Virtual Environment (Optional) Creating Virtual Environment (from the root of the project)\ -`python -m venv ./venv` +Windows: `python -m venv ./venv`\ +Linux: `virtualenv venv` Activate Virtual Environment:\ -`./venv/Scripts/activate` +Windows: `./venv/Scripts/activate`\ +Linux: `source venv/bin/activate` *_On Windows you might need to give rights to execute commands from PowerShell via the following command (running as Administrator)_\ `Set-ExecutionPolicy Unrestricted` *_If you want to exit Virtual Environment please run `deactivate`_ +#### Required Steps Update pip:\ `python -m pip install --upgrade pip` Install requirements:\ `pip install -r ./requirements.txt` - + ### Run Application: Run `python ./rss_parse/rss_reader.py --help` to find available options +### Cache +Application stores RSS Feed in a local storage in a temp folder (and rss_reader sub-folder).\ +For more info on what is considered a temp directory please look [here](https://docs.python.org/3/library/tempfile.html#tempfile.gettempdir) + +### Run Tests: +Run `pytest ./tests` to run tests + ### Package distributive: To create a distribution package please run\ -`pip install -e .` -You will be able to run `rss-reader` directly +`pip install -e .`\ +You will be able to run `rss_reader` directly\ +Also you should run this command as it makes the required font available for fpdf library diff --git a/fonts/OpenSans.ttf b/fonts/OpenSans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e21ff5f1ec01719e9e699df260db5be8b220aa2a GIT binary patch literal 129796 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
  • 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 CDHovt literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index d6e1198b..bbd900ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ -e . +pytest +pytest-mock diff --git a/rss_parse/__init__.py b/rss_parse/__init__.py index 6a35e852..4802e90f 100644 --- a/rss_parse/__init__.py +++ b/rss_parse/__init__.py @@ -1 +1 @@ -__version__ = "0.3" +__version__ = "1.0" diff --git a/rss_parse/exceptions/exceptions.py b/rss_parse/exceptions/exceptions.py index 4c84ba1d..87864e02 100644 --- a/rss_parse/exceptions/exceptions.py +++ b/rss_parse/exceptions/exceptions.py @@ -1,6 +1,19 @@ class ParsingException(Exception): + """ + An exception that could happen during RSS parsing + """ pass class CacheException(Exception): + """ + An exception that could happen during caching of RSS feed + """ + pass + + +class ProcessingException(Exception): + """ + An exception that could happen during RSS feed processing + """ pass diff --git a/rss_parse/parse/params.py b/rss_parse/parse/params.py index 6efe6292..8c17048e 100644 --- a/rss_parse/parse/params.py +++ b/rss_parse/parse/params.py @@ -3,13 +3,15 @@ class Params: Stores parameters to run rss reader. """ - def __init__(self, is_verbose, is_json, limit, source, pub_date): + def __init__(self, is_verbose, is_json, limit, source, pub_date, html_dir, pdf_dir): self.is_verbose = is_verbose self.is_json = is_json self.limit = limit self.source = source self.pub_date = pub_date + self.html_dir = html_dir + self.pdf_dir = pdf_dir @staticmethod def from_args(args): - return Params(args.verbose, args.json, args.limit, args.source, args.date) + return Params(args.verbose, args.json, args.limit, args.source, args.date, args.to_html, args.to_pdf) diff --git a/rss_parse/parse/rss_feed_cache.py b/rss_parse/parse/rss_cache.py similarity index 78% rename from rss_parse/parse/rss_feed_cache.py rename to rss_parse/parse/rss_cache.py index ca44ab79..f211cdec 100644 --- a/rss_parse/parse/rss_feed_cache.py +++ b/rss_parse/parse/rss_cache.py @@ -3,13 +3,16 @@ from rss_parse.exceptions.exceptions import CacheException, ParsingException from rss_parse.parse.rss_feed import RssFeed -from rss_parse.parse.rss_feed_mapper import RSS_FEED_JSON_MAPPER +from rss_parse.parse.rss_mapper import RSS_FEED_JSON_MAPPER from rss_parse.parse.rss_parser import RssJsonParser from rss_parse.utils.collection_utils import group_by, merge_by_key -from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP +from rss_parse.utils.messaging_utils import MESSAGE_CONSUMER_NOOP class TmpDirectoryCache: + """ + Class to store RSS Feed in a temporary directory + """ __DATE_TO_FILE_NAME_PATTERN = '%Y%m%d' def __init__(self, rss_feed, mc=MESSAGE_CONSUMER_NOOP): @@ -19,10 +22,16 @@ def __init__(self, rss_feed, mc=MESSAGE_CONSUMER_NOOP): @staticmethod def get_cache_base_path(): + """ + Returns the directory where all Cached files are stored + """ return os.path.join(tempfile.gettempdir(), "rss_reader") @staticmethod def get_cache_path(pub_date): + """ + Builds the path to the cache file based on a publication date + """ return os.path.join(TmpDirectoryCache.get_cache_base_path(), f"{pub_date.strftime(TmpDirectoryCache.__DATE_TO_FILE_NAME_PATTERN)}.json") @@ -46,11 +55,14 @@ def cache(self): all_items = merge_by_key([*existing_items, *new_items], key=lambda x: x.key()) all_feed = RssFeed(all_items) rss_json = RSS_FEED_JSON_MAPPER.to_json(all_feed) - with open(file_name, "w") as f: + with open(file_name, "w", encoding="UTF-8") as f: f.write(rss_json) class CacheJsonParser(RssJsonParser): + """ + Class to read RSS Feed from a cached directory + """ def __init__(self, date, source, mc=None): super().__init__(TmpDirectoryCache.get_cache_path(date), mc) @@ -58,6 +70,10 @@ def __init__(self, date, source, mc=None): self.__source = source def parse(self): + """ + Class to read RSS Feed from a cached directory. + Raises an exception if no news for the date found. + """ rss_feed = super().parse() items = rss_feed.rss_items if self.__source: diff --git a/rss_parse/parse/rss_feed.py b/rss_parse/parse/rss_feed.py index 33974f9d..234fa746 100644 --- a/rss_parse/parse/rss_feed.py +++ b/rss_parse/parse/rss_feed.py @@ -5,6 +5,10 @@ @dataclass class RssItem: + """ + Data class to store information about RSS Item/News + """ + title: str description: str publication_date: datetime @@ -18,4 +22,7 @@ def key(self): @dataclass class RssFeed: + """ + Data class to store a list of RSS Items + """ rss_items: List[RssItem] diff --git a/rss_parse/parse/rss_feed_mapper.py b/rss_parse/parse/rss_mapper.py similarity index 80% rename from rss_parse/parse/rss_feed_mapper.py rename to rss_parse/parse/rss_mapper.py index 97e68a76..2b486d01 100644 --- a/rss_parse/parse/rss_feed_mapper.py +++ b/rss_parse/parse/rss_mapper.py @@ -6,15 +6,18 @@ from rss_parse.utils.formatting_utils import format_date_pretty, get_description_plain -class RssFeedJsonMapper: +class RssJsonMapper: + """ + Class to do a conversion of RSS Feed TO and FROM json + """ __DATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" - def to_json(self, rss_feed: RssFeed, indent=0, pretty=False): + def to_json(self, rss_feed: RssFeed, indent=None, pretty=False): res = { RSS_ITEMS: [self.__item_to_json(item, pretty) for item in rss_feed.rss_items] } - return json.dumps(res, indent=indent) + return json.dumps(res, indent=indent, ensure_ascii=False) def __item_to_json(self, item: RssItem, pretty): res = { @@ -23,7 +26,7 @@ def __item_to_json(self, item: RssItem, pretty): } # Store as UTC publication_date = item.publication_date.astimezone(timezone.utc) \ - .strftime(RssFeedJsonMapper.__DATE_TIME_FORMAT) + .strftime(RssJsonMapper.__DATE_TIME_FORMAT) description = item.description if pretty: publication_date = format_date_pretty(item.publication_date) @@ -50,12 +53,12 @@ def from_json(self, rss_feed_json): def __parse_item(self, item): title = item[RSS_ITEM_TITLE] description = item.get(RSS_ITEM_DESCRIPTION, None) - publication_date = datetime.strptime(item[RSS_ITEM_PUB_DATE], RssFeedJsonMapper.__DATE_TIME_FORMAT) \ - .replace(tzinfo=timezone.utc).astimezone(timezone.utc) + publication_date = datetime.strptime(item[RSS_ITEM_PUB_DATE], RssJsonMapper.__DATE_TIME_FORMAT) \ + .replace(tzinfo=timezone.utc).astimezone() link = item[RSS_ITEM_LINK] image_url = item.get(RSS_IMAGE_ROOT, None) source = item.get(RSS_SOURCE, None) return RssItem(title, description, publication_date, link, image_url, source) -RSS_FEED_JSON_MAPPER = RssFeedJsonMapper() +RSS_FEED_JSON_MAPPER = RssJsonMapper() diff --git a/rss_parse/parse/rss_parser.py b/rss_parse/parse/rss_parser.py index 61adf242..6ee3a804 100644 --- a/rss_parse/parse/rss_parser.py +++ b/rss_parse/parse/rss_parser.py @@ -8,26 +8,32 @@ from rss_parse.exceptions.exceptions import ParsingException from rss_parse.parse.rss_feed import RssFeed, RssItem -from rss_parse.parse.rss_feed_mapper import RSS_FEED_JSON_MAPPER from rss_parse.parse.rss_keys import * -from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP +from rss_parse.parse.rss_mapper import RSS_FEED_JSON_MAPPER +from rss_parse.utils.messaging_utils import MESSAGE_CONSUMER_NOOP from rss_parse.utils.parsing_utils import sanitize_text, to_date class RssParser(ABC): + """ + Abstraction to parse RSS Feed from different sources (URL, XML, JSON, etc.) + """ def __init__(self, mc=MESSAGE_CONSUMER_NOOP): self._mc = mc @abstractmethod - def parse(self): + def parse(self) -> RssFeed: """ - :rtype: rss_parse.rss_feed.RssFeed + Reads and Returns Rss Feed from some source. """ pass class RssJsonParser(RssParser): + """ + Implementation of RSSParser that reads RSS Feed from a file in a json format + """ def __init__(self, file_name, mc=None): super().__init__(mc) @@ -37,12 +43,15 @@ def __init__(self, file_name, mc=None): def parse(self): if not os.path.exists(self.__file_name): return RssFeed([]) - with open(self.__file_name, "r") as f: + with open(self.__file_name, "r", encoding="UTF-8") as f: rss_json = f.read() return RSS_FEED_JSON_MAPPER.from_json(rss_json) class RssXmlParser(RssParser): + """ + Implementation of RSSParser that reads RSS Feed from an XML string + """ def __init__(self, xml_feed, mc=None): super().__init__(mc) @@ -55,7 +64,6 @@ def parse(self): try: rss_feed_dict = xmltodict.parse(self.__xml_feed)[RSS_ROOT] except (xml.parsers.expat.ExpatError, KeyError): - # TODO: include original exception to the raise ParsingException("Source doesn't contain a valid RSS Feed.") self._mc.add_message("Parsing items info") @@ -101,9 +109,11 @@ def __validate_correctness(self, item: RssItem): class RssUrlParser(RssParser): + """ + Implementation of RSSParser that reads RSS Feed from URL in XML format + """ def __init__(self, source, mc=None): - # FIXME: assert source is not None, "Source URL is required" super().__init__(mc) self.__source = source self._mc = mc @@ -112,7 +122,8 @@ def parse(self): try: self._mc.add_message(f"Reaching out to {self.__source}") with requests.get(self.__source) as f: - # TODO: add error handling + if f.status_code != 200: + raise Exception rss_raw_xml = f.text except (InvalidSchema, InvalidURL, MissingSchema): self._mc.add_message(f"Encountered an error during reading RSS Feed from URL") diff --git a/rss_parse/parse/rss_parser_factory.py b/rss_parse/parse/rss_parser_factory.py index cbcd544f..302e42d0 100644 --- a/rss_parse/parse/rss_parser_factory.py +++ b/rss_parse/parse/rss_parser_factory.py @@ -1,9 +1,12 @@ -from rss_parse.parse.rss_feed_cache import CacheJsonParser +from rss_parse.parse.rss_cache import CacheJsonParser from rss_parse.parse.rss_parser import RssUrlParser -from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP +from rss_parse.utils.messaging_utils import MESSAGE_CONSUMER_NOOP -def get_parser(date, source, mc=MESSAGE_CONSUMER_NOOP): - if date: - return CacheJsonParser(date, source, mc=mc) - return RssUrlParser(source, mc=mc) +def get_parser(params, mc=MESSAGE_CONSUMER_NOOP): + """ + Fetch correct implementation of RssParser based on input parameters + """ + if params.pub_date: + return CacheJsonParser(params.pub_date, params.source, mc=mc) + return RssUrlParser(params.source, mc=mc) diff --git a/rss_parse/preprocessor/__init__.py b/rss_parse/preprocessor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rss_parse/preprocessor/rss_preprocessor.py b/rss_parse/preprocessor/rss_preprocessor.py new file mode 100644 index 00000000..381958f3 --- /dev/null +++ b/rss_parse/preprocessor/rss_preprocessor.py @@ -0,0 +1,61 @@ +from abc import ABC, abstractmethod + +from rss_parse.exceptions.exceptions import CacheException +from rss_parse.parse.rss_cache import TmpDirectoryCache +from rss_parse.parse.rss_feed import RssFeed +from rss_parse.utils.messaging_utils import MESSAGE_CONSUMER_NOOP + + +class RssPreprocessor(ABC): + """ + Abstraction to do preprocess/modify RSS Feed + """ + + def __init__(self, mc=MESSAGE_CONSUMER_NOOP): + self._mc = mc + + @abstractmethod + def preprocess(self, rss_feed: RssFeed) -> RssFeed: + """ + Method gets RSS Feed as an input and returns modified view of it + """ + pass + + +class RssCachePreprocessor(RssPreprocessor): + """ + Implementation of RSSPreprocessor that stores RSS Feed in cache + """ + + def preprocess(self, rss_feed): + self._mc.add_message("Trying to add fetched news to the local cache") + try: + rss_cache = TmpDirectoryCache(rss_feed, mc=self._mc) + rss_cache.cache() + except CacheException: + self._mc.add_message("Unable to save RSS Feed to cache. Proceeding...") + return rss_feed + + +class RssSortPreprocessor(RssPreprocessor): + """ + Implementation of RSSPreprocessor that sorts RSS Feed by publication date descending + """ + + def preprocess(self, rss_feed): + rss_items = sorted(rss_feed.rss_items, key=lambda item: item.publication_date, reverse=True) + return RssFeed(rss_items) + + +class RssLimitPreprocessor(RssPreprocessor): + """ + Implementation of RSSPreprocessor that gets limited number of RSS Items from RSS Feed + """ + + def __init__(self, limit, mc=None): + super().__init__(mc) + self.__limit = limit + + def preprocess(self, rss_feed): + rss_items = rss_feed.rss_items[:self.__limit] + return RssFeed(rss_items) diff --git a/rss_parse/preprocessor/rss_preprocessor_factory.py b/rss_parse/preprocessor/rss_preprocessor_factory.py new file mode 100644 index 00000000..7e9be96f --- /dev/null +++ b/rss_parse/preprocessor/rss_preprocessor_factory.py @@ -0,0 +1,14 @@ +from rss_parse.preprocessor.rss_preprocessor import RssSortPreprocessor, RssCachePreprocessor, RssLimitPreprocessor +from rss_parse.utils.messaging_utils import MESSAGE_CONSUMER_NOOP + + +def get_preprocessors(params, mc=MESSAGE_CONSUMER_NOOP): + """ + Fetch correct implementation of RssPreprocessor based on input parameters + """ + preprocessors = [RssSortPreprocessor(mc)] + if not params.pub_date: + preprocessors.append(RssCachePreprocessor(mc)) + if params.limit: + preprocessors.append(RssLimitPreprocessor(params.limit, mc)) + return preprocessors diff --git a/rss_parse/processor/rss_html_converter.py b/rss_parse/processor/rss_html_converter.py new file mode 100644 index 00000000..29dd4cad --- /dev/null +++ b/rss_parse/processor/rss_html_converter.py @@ -0,0 +1,50 @@ +import os.path + +from rss_parse.exceptions.exceptions import ProcessingException +from rss_parse.processor.rss_processor import RssProcessor +from rss_parse.utils.formatting_utils import format_date_pretty + + +class RssToHtmlConverter(RssProcessor): + """ + Converts RSS to an HTML Format and saves it in a file + """ + HTML_FILE_NAME = "rss_feed.html" + + def __init__(self, rss_feed, dir, mc=None): + super().__init__(rss_feed, mc=mc) + self.__dir = dir + if not os.path.exists(dir): + raise ProcessingException(f"Path {dir} doesn't exist") + + def process(self): + html_res = self.__convert_to_html() + with open(os.path.join(self.__dir, RssToHtmlConverter.HTML_FILE_NAME), "w", encoding="UTF-8") as f: + f.write(html_res) + + def __convert_to_html(self): + html_res = ( + '' + '' + '' + 'RSS Feed' + '' + '' + '

    RSS Feed

    ' + ) + + for item in self.rss_feed.rss_items: + html_res += '
    ' + html_res += f'

    {item.title}

    ' + html_res += f'

    {format_date_pretty(item.publication_date)}

    ' + html_res += f'

    Link

    ' + if item.image_url: + html_res += f'
    ' + + if item.description: + html_res += f'

    {item.description}

    ' + + html_res += '


    ' + html_res += '' + + return html_res diff --git a/rss_parse/processor/rss_pdf_converter.py b/rss_parse/processor/rss_pdf_converter.py new file mode 100644 index 00000000..4a7646e2 --- /dev/null +++ b/rss_parse/processor/rss_pdf_converter.py @@ -0,0 +1,55 @@ +import os.path + +from fpdf import FPDF, HTMLMixin + +from rss_parse.exceptions.exceptions import ProcessingException +from rss_parse.processor.rss_processor import RssProcessor +from rss_parse.utils.formatting_utils import format_date_pretty + + +class PdfWithHtml(FPDF, HTMLMixin): + pass + + +class RssToPdfConverter(RssProcessor): + """ + Converts RSS to a PDF Format and saves it in a file + """ + PDF_FILE_NAME = "rss_feed.pdf" + + def __init__(self, rss_feed, dir, mc=None): + super().__init__(rss_feed, mc=mc) + self.__dir = dir + if not os.path.exists(dir): + raise ProcessingException(f"Path {dir} doesn't exist") + + def process(self): + pdf = PdfWithHtml() + pdf.add_font('OpenSans', '', 'OpenSans.ttf', uni=True) + pdf.set_font('OpenSans', size=12) + pdf.add_page() + + items = self.rss_feed.rss_items + for index, item in enumerate(items): + pdf.multi_cell(w=0, h=5, txt=item.title, new_x="LEFT") + pdf.multi_cell(w=0, h=5, txt="", new_x="LEFT") + pdf.multi_cell(w=0, h=5, txt=f"Date: {format_date_pretty(item.publication_date)}", new_x="LEFT") + pdf.multi_cell(w=0, h=5, txt="", new_x="LEFT") + pdf.set_text_color(0, 0, 255) + pdf.multi_cell(w=0, h=5, txt=item.link, new_x="LEFT") + pdf.set_text_color(0, 0, 0) + pdf.multi_cell(w=0, h=5, txt="", new_x="LEFT") + try: + if item.image_url: + pdf.image(item.image_url, h=70) + pdf.multi_cell(w=0, h=5, txt="", new_x="LEFT") + except: + pass + + if item.description: + pdf.write_html(item.description) + + if index != len(items) - 1: + pdf.add_page() + + pdf.output(os.path.join(self.__dir, RssToPdfConverter.PDF_FILE_NAME)) diff --git a/rss_parse/processor/rss_processor.py b/rss_parse/processor/rss_processor.py index 40db8fc9..3f1f2bc9 100644 --- a/rss_parse/processor/rss_processor.py +++ b/rss_parse/processor/rss_processor.py @@ -1,23 +1,32 @@ from abc import ABC, abstractmethod from rss_parse.parse.rss_feed import RssFeed, RssItem -from rss_parse.parse.rss_feed_mapper import RSS_FEED_JSON_MAPPER +from rss_parse.parse.rss_mapper import RSS_FEED_JSON_MAPPER from rss_parse.utils.formatting_utils import format_date_pretty, get_description_plain -from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP +from rss_parse.utils.messaging_utils import MESSAGE_CONSUMER_NOOP class RssProcessor(ABC): + """ + Abstraction to do processing on RSS Feed (print, store it, save in db, post it somewhere, etc.) + """ - def __init__(self, rss_feed, mc=MESSAGE_CONSUMER_NOOP): + def __init__(self, rss_feed: RssFeed, mc=MESSAGE_CONSUMER_NOOP): self.rss_feed = rss_feed self._mc = mc @abstractmethod def process(self): + """ + Do some processing of RSS Feed + """ pass class RssPrinter(RssProcessor): + """ + Implementation of RSSProcessor that prints RSS Feed in a human-readable form to console + """ __SEPARATOR = "----------" def __init__(self, rss_feed: RssFeed, file_descriptor, mc=None): @@ -57,6 +66,9 @@ def __print(self, v="", sep=None): class RssJsonPrinter(RssProcessor): + """ + Implementation of RSSProcessor that prints RSS Feed in a human-readable form to console as json + """ def process(self): self._mc.add_message("Converting to json") diff --git a/rss_parse/processor/rss_processor_factory.py b/rss_parse/processor/rss_processor_factory.py index 54dac5c0..fc9b8350 100644 --- a/rss_parse/processor/rss_processor_factory.py +++ b/rss_parse/processor/rss_processor_factory.py @@ -1,10 +1,26 @@ import sys +from rss_parse.processor.rss_html_converter import RssToHtmlConverter +from rss_parse.processor.rss_pdf_converter import RssToPdfConverter from rss_parse.processor.rss_processor import RssPrinter, RssJsonPrinter -from rss_parse.utils.message_consumer import MESSAGE_CONSUMER_NOOP +from rss_parse.utils.messaging_utils import MESSAGE_CONSUMER_NOOP -def get_processor(rss_feed, is_json=False, mc=MESSAGE_CONSUMER_NOOP): - if is_json: - return RssJsonPrinter(rss_feed, mc=mc) - return RssPrinter(rss_feed, sys.stdout, mc=mc) +def get_processors(rss_feed, params, mc=MESSAGE_CONSUMER_NOOP): + """ + Fetch correct implementation of RssProcessor based on input parameters + """ + processors = [] + + if params.is_json: + processors.append(RssJsonPrinter(rss_feed, mc=mc)) + else: + processors.append(RssPrinter(rss_feed, sys.stdout, mc=mc)) + + if params.html_dir: + processors.append(RssToHtmlConverter(rss_feed, params.html_dir, mc=mc)) + + if params.pdf_dir: + processors.append(RssToPdfConverter(rss_feed, params.pdf_dir, mc=mc)) + + return processors diff --git a/rss_parse/rss_reader.py b/rss_parse/rss_reader.py index 6d19b3c1..dd4fd825 100644 --- a/rss_parse/rss_reader.py +++ b/rss_parse/rss_reader.py @@ -2,21 +2,21 @@ import sys from rss_parse import __version__ -from rss_parse.exceptions.exceptions import CacheException +from rss_parse.exceptions.exceptions import ProcessingException from rss_parse.parse.params import Params -from rss_parse.parse.rss_feed import RssFeed -from rss_parse.parse.rss_feed_cache import TmpDirectoryCache from rss_parse.parse.rss_parser import ParsingException from rss_parse.parse.rss_parser_factory import get_parser -from rss_parse.processor.rss_processor_factory import get_processor -from rss_parse.utils.arg_parse_types import date_YYYYMMDD -from rss_parse.utils.message_consumer import get_message_consumer -from rss_parse.utils.printing_utils import print_error +from rss_parse.preprocessor.rss_preprocessor_factory import get_preprocessors +from rss_parse.processor.rss_html_converter import RssToHtmlConverter +from rss_parse.processor.rss_pdf_converter import RssToPdfConverter +from rss_parse.processor.rss_processor_factory import get_processors +from rss_parse.utils.arg_parse_types import date_YYYYMMDD, dir_path +from rss_parse.utils.messaging_utils import get_message_consumer +from rss_parse.utils.messaging_utils import print_error def parse_params_from_arguments(): parser = argparse.ArgumentParser(description="Pure Python command-line RSS reader.") - # FIXME: Doesn't work with parse_args([*]). Look at action parser.add_argument("source", help="RSS URL", nargs='?' if '--date' in sys.argv else None) parser.add_argument("--version", help="Print version info", action="version", version=f"Version {__version__}") @@ -24,66 +24,60 @@ def parse_params_from_arguments(): parser.add_argument("--verbose", help="Output verbose status messages", action="store_true") parser.add_argument("--limit", help="Limit news topics if this parameter provided", type=int, default=-1) parser.add_argument("--date", help="Limit the feed by publication date - format YYYYMMDD", type=date_YYYYMMDD) + parser.add_argument("--to-html", + help=f"Directory to store generated html file. " + f"File name will be {RssToHtmlConverter.HTML_FILE_NAME}", + type=dir_path) + parser.add_argument("--to-pdf", + help=f"Directory to store generated pdf file. " + f"File name will be {RssToPdfConverter.PDF_FILE_NAME}", + type=dir_path) args = parser.parse_args() return Params.from_args(args) def main(): - """ - Add args, which will be available, when you will work with console. - """ + # Parse arguments from console params = parse_params_from_arguments() mc = get_message_consumer(params.is_verbose) mc.add_message("Program started") mc.add_message("Initializing parser...") - parser = get_parser(params.pub_date, params.source, mc) + parser = get_parser(params, mc) mc.add_message("Parser initialized") rss_feed = None try: + # parse RSS from different sources (cache, URL, XML file, etc.) rss_feed = parser.parse() except ParsingException as ex: mc.add_message("Encountered an error during parsing") print_error(str(ex)) mc.add_message("Exiting the program") - # FIXME: Map Exceptions to errors or something like this exit(2) - # FIXME: Ideally I need to skip caching if I took it from cache - mc.add_message("Trying to add fetched news to the local cache") - try: - tmp_cache = TmpDirectoryCache(rss_feed) - tmp_cache.cache() - except CacheException: - mc.add_message("Unable to save RSS Feed to cache. Proceeding...") - - # Fixme: move to preprocessor - rss_items = sorted(rss_feed.rss_items, key=lambda item: item.publication_date, reverse=True) - if params.limit > 0: - mc.add_message("Applying limit option") - rss_items = rss_items[:params.limit] - rss_feed = RssFeed(rss_items) - - processor = get_processor(rss_feed, params.is_json, mc) - processor.process() + # modify original feed based on some needs (sorting, limit, etc) + preprocessors = get_preprocessors(params, mc) + for preprocessor in preprocessors: + rss_feed = preprocessor.preprocess(rss_feed) - -# FIXME: REMOVE -import traceback + # do something with the feed (print, convert, save as file) + try: + processors = get_processors(rss_feed, params, mc) + for processor in processors: + processor.process() + except ProcessingException as ex: + print_error(str(ex)) def main_wrapper(): try: main() exit(0) - except Exception as e: - # FIXME: REMOVE IN LATEST VERSION - print(traceback.format_exc()) + except Exception: print_error("Unknown error, please rerun the application") - # TODO: logging exit(1) diff --git a/rss_parse/utils/arg_parse_types.py b/rss_parse/utils/arg_parse_types.py index 912f5eb8..96265977 100644 --- a/rss_parse/utils/arg_parse_types.py +++ b/rss_parse/utils/arg_parse_types.py @@ -1,9 +1,23 @@ import argparse +import os.path from datetime import datetime def date_YYYYMMDD(s): + """ + argparse type that reads date in a format of YYYYMMDD + """ try: return datetime.strptime(s, "%Y%m%d") except ValueError: raise argparse.ArgumentTypeError(f"Not a valid format of date: {s}") + + +def dir_path(dir): + """ + argparse type that represents an existing directory + """ + if os.path.isdir(dir): + return dir + else: + raise argparse.ArgumentTypeError(f"{dir} should be an existing directory") diff --git a/rss_parse/utils/collection_utils.py b/rss_parse/utils/collection_utils.py index a9ebf6f1..8d09bb10 100644 --- a/rss_parse/utils/collection_utils.py +++ b/rss_parse/utils/collection_utils.py @@ -2,6 +2,9 @@ def group_by(it, key=lambda x: x): + """ + Group an iterable by key + """ d = defaultdict(list) for item in it: d[key(item)].append(item) @@ -9,6 +12,9 @@ def group_by(it, key=lambda x: x): def merge_by_key(it, key=lambda x: x): + """ + Merge an iterable by key. If the key is the same for multiple items, only the latest stays + """ d = {} for item in it: d[key(item)] = item diff --git a/rss_parse/utils/formatting_utils.py b/rss_parse/utils/formatting_utils.py index 35b6a162..5284d4a4 100644 --- a/rss_parse/utils/formatting_utils.py +++ b/rss_parse/utils/formatting_utils.py @@ -12,12 +12,18 @@ def __configure_translator(): def format_date_pretty(pub_date): + """ + Format date in a human-readable form + """ if not pub_date: return "" return pub_date.strftime("%a, %d %b %Y %H:%M:%S %z") def get_description_plain(description): + """ + Format a text that might be an HTML by parsing its tags and conveting them to plain text alternatives + """ if not description: return description desc = description.strip() diff --git a/rss_parse/utils/message_consumer.py b/rss_parse/utils/messaging_utils.py similarity index 56% rename from rss_parse/utils/message_consumer.py rename to rss_parse/utils/messaging_utils.py index 7d9499a8..999d733f 100644 --- a/rss_parse/utils/message_consumer.py +++ b/rss_parse/utils/messaging_utils.py @@ -1,7 +1,11 @@ +import sys from abc import ABC, abstractmethod class MessageConsumer(ABC): + """ + Abstraction for adding messages + """ @abstractmethod def add_message(self, message): @@ -9,12 +13,18 @@ def add_message(self, message): class MessageConsumerNoop(MessageConsumer): + """ + Implementation of MessageConsumer that skips messages and does nothing. + """ def add_message(self, message): pass class VerboseMessageConsumer(MessageConsumer): + """ + Implementation of MessageConsumer that prints messages surrounding them with [[[ msg ]]] + """ def add_message(self, message): print(f'[[[ {message} ]]]') @@ -27,3 +37,10 @@ def get_message_consumer(is_verbose): MESSAGE_CONSUMER_NOOP = MessageConsumerNoop() + + +def print_error(*args, **kwargs): + """ + Shortcut to print error messages to a console + """ + print(*args, file=sys.stderr, **kwargs) diff --git a/rss_parse/utils/parsing_utils.py b/rss_parse/utils/parsing_utils.py index 8ad067c5..69a92cf6 100644 --- a/rss_parse/utils/parsing_utils.py +++ b/rss_parse/utils/parsing_utils.py @@ -3,10 +3,16 @@ def sanitize_text(txt): + """ + Removes some encoded text from a string + """ return txt.replace(" ", " ") def to_date(date_str): + """ + Reads a date from a string and converts in to a user timezone + """ if not date_str: return None try: diff --git a/rss_parse/utils/printing_utils.py b/rss_parse/utils/printing_utils.py deleted file mode 100644 index 0c2a1f6b..00000000 --- a/rss_parse/utils/printing_utils.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys - - -def print_error(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) diff --git a/setup.py b/setup.py index c20ef47b..5e2a4f13 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,10 @@ -from setuptools import setup, find_packages - import re +import shutil + +from setuptools import setup, find_packages +from setuptools.command.develop import develop +from setuptools.command.egg_info import egg_info +from setuptools.command.install import install VERSION_FILE = 'rss_parse/__init__.py' @@ -17,26 +21,81 @@ def version(): return ver +fonts_installed = False + + +def install_fdpf_fonts(): + global fonts_installed + if fonts_installed: + return + + try: + import os + import fpdf + + local_fonts_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts") + + fpdf_fonts_dir = os.path.join(os.path.dirname(fpdf.__file__), 'font') + if not os.path.exists(fpdf_fonts_dir): + os.mkdir(fpdf_fonts_dir) + + font_file_names = [f for f in os.listdir(local_fonts_dir) if f.endswith(".ttf")] + for font_file_name in font_file_names: + full_file_name = os.path.join(local_fonts_dir, font_file_name) + shutil.copy(full_file_name, fpdf_fonts_dir) + + fonts_installed = True + except ModuleNotFoundError: + pass + + +class CustomInstallCommand(install): + + def run(self): + install.run(self) + install_fdpf_fonts() + + +class CustomDevelopCommand(develop): + + def run(self): + develop.run(self) + install_fdpf_fonts() + + +class CustomEggInfoCommand(egg_info): + + def run(self): + egg_info.run(self) + install_fdpf_fonts() + + setup( name='rss_reader', version=version(), description='Pure Python command-line RSS reader.', author='Aleksandra Khorosheva', zip_safe=False, - author_email='alexa.horoschewa@gmail.com', + author_email='Aleksandra_Khorosheva@epam.com', keywords=['RSS Reader', 'RSS Feed Parser'], install_requires=[ 'setuptools~=57.0.0', 'requests~=2.27.1', 'xmltodict~=0.13.0', + 'fpdf2>=2.5.5', 'python-dateutil~=2.8.0', 'html2text>=2020.1.16', ], - python_requires=">=3.9", + python_requires=">=3.8", packages=find_packages(), entry_points={ 'console_scripts': [ 'rss_reader=rss_parse.rss_reader:main_wrapper', ], }, + cmdclass={ + 'install': CustomInstallCommand, + 'develop': CustomDevelopCommand, + 'egg_info': CustomEggInfoCommand, + }, ) diff --git a/tests/it/test_it_arg_parse_types.py b/tests/it/test_it_arg_parse_types.py new file mode 100644 index 00000000..b3b4f93e --- /dev/null +++ b/tests/it/test_it_arg_parse_types.py @@ -0,0 +1,16 @@ +import argparse +from datetime import datetime + +import pytest + +from rss_parse.utils.arg_parse_types import date_YYYYMMDD + + +def test_date_YYYYMMDD_parsing(): + actual = date_YYYYMMDD("20201010") + assert actual == datetime(2020, 10, 10) + + +def test_date_YYYYMMDD_invalid_format_error(): + with pytest.raises(argparse.ArgumentTypeError): + date_YYYYMMDD("2020-10-10") diff --git a/tests/unit/test_collection_utils.py b/tests/unit/test_collection_utils.py new file mode 100644 index 00000000..b2ffd524 --- /dev/null +++ b/tests/unit/test_collection_utils.py @@ -0,0 +1,37 @@ +from rss_parse.utils.collection_utils import group_by, merge_by_key + + +def test_group_by_no_key(): + actual = group_by([1, 1, 2, 3, 2]) + assert actual == dict([(1, [1] * 2), (2, [2] * 2), (3, [3])]).items() + + +def test_group_by_modifying_key(): + actual = group_by([1, 1, 2, 3, 2], key=lambda x: x // 2) + assert actual == dict([(0, [1, 1]), (1, [2, 3, 2])]).items() + + +def test_group_by_field(): + def a_b(a, b): + return {"a": a, "b": b} + + actual = group_by([a_b(1, 2), a_b(2, 3), a_b(2, "a"), a_b("c", 3)], key=lambda x: x["a"]) + assert actual == dict([(1, [a_b(1, 2)]), (2, [a_b(2, 3), a_b(2, "a")]), ("c", [a_b("c", 3)])]).items() + + +def test_merge_by_key_distinct(): + actual = merge_by_key([1, 1, 1, 2, 3, 2]) + assert actual == [1, 2, 3] + + +def test_merge_by_key_modifying_key(): + actual = merge_by_key([1, 1, 1, 2, 3, 2, 3, 4], lambda x: x // 2) + assert actual == [1, 3, 4] + + +def test_merge_by_key_field(): + def a_b(a, b): + return {"a": a, "b": b} + + actual = merge_by_key([a_b(1, 2), a_b(1, 3), a_b(1, 4), a_b(2, 3), a_b(3, 2)], key=lambda x: x["a"]) + assert actual == [a_b(1, 4), a_b(2, 3), a_b(3, 2)] diff --git a/tests/unit/test_messaging_utils.py b/tests/unit/test_messaging_utils.py new file mode 100644 index 00000000..7ebfb926 --- /dev/null +++ b/tests/unit/test_messaging_utils.py @@ -0,0 +1,13 @@ +from rss_parse.utils.messaging_utils import print_error + + +def test_print_error_stdout_empty(capfd): + print_error("Not stdout") + out, err = capfd.readouterr() + assert not out + + +def test_print_error_stderr_not_empty(capfd): + print_error("Definitely stderr") + out, err = capfd.readouterr() + assert err == "Definitely stderr\n" diff --git a/tests/unit/test_parsing_utils.py b/tests/unit/test_parsing_utils.py new file mode 100644 index 00000000..e5c4ea38 --- /dev/null +++ b/tests/unit/test_parsing_utils.py @@ -0,0 +1,26 @@ +from dateutil.parser import ParserError +from pytest_mock import MockerFixture + +from rss_parse.utils.parsing_utils import sanitize_text, to_date + + +def test_sanitize_text_simple_text_not_changed(): + actual = sanitize_text("abcd xyz 123") + assert actual == "abcd xyz 123" + + +def test_sanitize_text_nbsp_becomes_space(): + actual = sanitize_text("a   c") + assert actual == "a c" + + +def test_to_date_empty_input_none(): + actual = to_date("") + assert actual is None + + +def test_to_date_exception_error(mocker: MockerFixture): + mocker.patch('dateutil.parser.parse', side_effect=ParserError()) + + actual = to_date("1996-04-02 12:12:12") + assert actual is None diff --git a/tests/unit/test_rss_feed_mapper.py b/tests/unit/test_rss_feed_mapper.py new file mode 100644 index 00000000..718e35d2 --- /dev/null +++ b/tests/unit/test_rss_feed_mapper.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone + +from rss_parse.parse.rss_feed import RssItem, RssFeed +from rss_parse.parse.rss_mapper import RssJsonMapper + + +def test_rss_json_mapper_to_json(): + rss_feed = RssFeed([ + RssItem("title1", "description1", datetime(2020, 11, 11, tzinfo=timezone.utc), "link1", "image_url1", + source="source1"), + RssItem("title2", "description2", datetime(2020, 10, 10, tzinfo=timezone.utc), "link2", "image_url2", + source="source2"), + ]) + expected = '{"item": [' \ + '{"title": "title1", "link": "link1", "pubDate": "2020-11-11 00:00:00", ' \ + '"description": "description1", "image": "image_url1", "source": "source1"}, ' \ + '{"title": "title2", "link": "link2", "pubDate": "2020-10-10 00:00:00", ' \ + '"description": "description2", "image": "image_url2", "source": "source2"}' \ + ']}' + + actual = RssJsonMapper().to_json(rss_feed) + + assert actual == expected + + +def test_rss_json_mapper_from_json(): + expected = RssFeed([ + RssItem("title1", "description1", datetime(2020, 11, 11, tzinfo=timezone.utc).astimezone(), "link1", + "image_url1", source="source1"), + RssItem("title2", "description2", datetime(2020, 10, 10, tzinfo=timezone.utc).astimezone(), "link2", + "image_url2", source="source2"), + ]) + rss_json = '{"item": [' \ + '{"title": "title1", "link": "link1", "pubDate": "2020-11-11 00:00:00", ' \ + '"description": "description1", "image": "image_url1", "source": "source1"}, ' \ + '{"title": "title2", "link": "link2", "pubDate": "2020-10-10 00:00:00", ' \ + '"description": "description2", "image": "image_url2", "source": "source2"}' \ + ']}' + + actual = RssJsonMapper().from_json(rss_json) + + assert actual == expected diff --git a/tests/unit/test_rss_feed_preprocessor.py b/tests/unit/test_rss_feed_preprocessor.py new file mode 100644 index 00000000..7cb1bbda --- /dev/null +++ b/tests/unit/test_rss_feed_preprocessor.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from rss_parse.parse.rss_feed import RssFeed, RssItem +from rss_parse.preprocessor.rss_preprocessor import RssSortPreprocessor, RssLimitPreprocessor + + +def rss_item(pub_date): + return RssItem("", "", pub_date, "", "") + + +def test_rss_sort_preprocessor(): + d1 = rss_item(datetime(2019, 10, 28)) + d2 = rss_item(datetime(2018, 10, 28)) + d3 = rss_item(datetime(2020, 8, 12, 12, 12, 14)) + d4 = rss_item(datetime(2020, 8, 12, 11, 12, 14)) + rss_feed = RssFeed([d1, d2, d3, d4]) + preprocessor = RssSortPreprocessor() + actual = preprocessor.preprocess(rss_feed) + assert actual == RssFeed([d3, d4, d1, d2]) + + +def test_rss_limit_preprocessor_shrink(): + d1 = rss_item(datetime(2019, 10, 28)) + d2 = rss_item(datetime(2018, 10, 28)) + d3 = rss_item(datetime(2020, 8, 12, 12, 12, 14)) + d4 = rss_item(datetime(2020, 8, 12, 11, 12, 14)) + rss_feed = RssFeed([d1, d2, d3, d4]) + preprocessor = RssLimitPreprocessor(2) + actual = preprocessor.preprocess(rss_feed) + assert actual == RssFeed([d1, d2]) + + +def test_rss_limit_preprocessor_more_than_max_all(): + d1 = rss_item(datetime(2019, 10, 28)) + d2 = rss_item(datetime(2018, 10, 28)) + d3 = rss_item(datetime(2020, 8, 12, 12, 12, 14)) + d4 = rss_item(datetime(2020, 8, 12, 11, 12, 14)) + rss_feed = RssFeed([d1, d2, d3, d4]) + preprocessor = RssLimitPreprocessor(20) + actual = preprocessor.preprocess(rss_feed) + assert actual == rss_feed