diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index ef1ab63..0000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[bumpversion] -current_version = 1.0.0 -commit = False -tag = False - -[bumpversion:file:setup.py] - -[bumpversion:file:reader/__init__.py] - -[bumpversion:file:reader/__main__.py] - diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..b783175 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,13 @@ +--- +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..2f647cc --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,26 @@ +name: Lint Python Code + +on: + pull_request: + branches: [ master ] + push: + branches: [ master ] + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ruff + + - name: Run Ruff + run: ruff check --output-format=github diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..512e334 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,42 @@ +name: Publish to PyPI +on: + push: + tags: + - '*.*.*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install .[build] + + - name: Build package + run: python -m build + + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Test publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.TESTPYPI_API_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create ${{ github.ref_name }} ./dist/* --generate-notes diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..2f6ec97 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,33 @@ +name: Run Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_call: + workflow_dispatch: + +jobs: + testing: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install .[dev] + + - name: Run Pytest + run: | + pytest diff --git a/MANIFEST.in b/MANIFEST.in index 8d401be..83ce77d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include reader/*.cfg +include src/reader/*.toml diff --git a/README.md b/README.md index 9394aba..1a0111d 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ For more information see the tutorial [How to Publish an Open-Source Python Pack You can install the Real Python Feed Reader from [PyPI](https://pypi.org/project/realpython-reader/): - pip install realpython-reader + python -m pip install realpython-reader -The reader is supported on Python 2.7, as well as Python 3.4 and above. +The reader is supported on Python 3.7 and above. Older versions of Python, including Python 2.7, are supported by version 1.0.0 of the reader. ## How to use -The Real Python Feed Reader is a command line application, named `realpython`. To see a list of the [latest Real Python tutorials](https://realpython.com/) simply call the program: +The Real Python Feed Reader is a command line application, named `realpython`. To see a list of the [latest Real Python tutorials](https://realpython.com/), call the program without any arguments: $ realpython The latest tutorials from Real Python (https://realpython.com/) @@ -61,5 +61,4 @@ You can also call the Real Python Feed Reader in your own Python code, by import >>> from reader import feed >>> feed.get_titles() - ['Splitting, Concatenating, and Joining Strings in Python', ...] - + ['How to Publish an Open-Source Python Package to PyPI', ...] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b2be1ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "realpython-reader" +version = "1.1.4" +description = "Read the latest Real Python tutorials" +readme = "README.md" +authors = [{ name = "Real Python", email = "info@realpython.com" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = ["feed", "reader", "tutorial"] +dependencies = ["feedparser", "html2text", 'tomli; python_version < "3.11"'] +requires-python = ">=3.9" + + [project.optional-dependencies] + build = ["build", "twine"] + dev = ["black", "bumpver", "isort", "mypy", "pytest"] + + [project.scripts] + realpython = "reader.__main__:main" + + [project.urls] + repository = "https://github.com/realpython/reader" + documentation = "https://realpython.com/pypi-publish-python-package/" + + +[tool.bumpver] +current_version = "1.1.4" +version_pattern = "MAJOR.MINOR.PATCH" +commit_message = "bump version {old_version} -> {new_version}" +commit = true +tag = true +push = false + + [tool.bumpver.file_patterns] + "pyproject.toml" = [ + 'current_version = "{version}"', + 'version = "{version}"', + ] + "src/reader/__init__.py" = ["{version}"] + "src/reader/__main__.py" = ["- realpython-reader v{version}"] + +[tool.isort] +profile = "black" +import_heading_stdlib = "Standard library imports" +import_heading_thirdparty = "Third party imports" +import_heading_firstparty = "Reader imports" + +[tool.mypy] +strict = true + + [[tool.mypy.overrides]] + module = "feedparser" + ignore_missing_imports = true \ No newline at end of file diff --git a/reader/__init__.py b/reader/__init__.py deleted file mode 100644 index 0623b57..0000000 --- a/reader/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Real Python feed reader - -Import the `feed` module to work with the Real Python feed: - - >>> from reader import feed - >>> feed.get_titles() - ['Logging in Python', 'The Best Python Books', ...] - -See https://github.com/realpython/reader/ for more information -""" -import importlib_resources as _resources -try: - from configparser import ConfigParser as _ConfigParser -except ImportError: # Python 2 - from ConfigParser import ConfigParser as _ConfigParser - - -# Version of realpython-reader package -__version__ = "1.0.0" - -# Read URL of feed from config file -_cfg = _ConfigParser() -with _resources.path("reader", "config.cfg") as _path: - _cfg.read(str(_path)) -URL = _cfg.get("feed", "url") diff --git a/reader/config.cfg b/reader/config.cfg deleted file mode 100644 index 3c6ea8a..0000000 --- a/reader/config.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[feed] -url = https://realpython.com/atom.xml diff --git a/reader/feed.py b/reader/feed.py deleted file mode 100644 index 7854637..0000000 --- a/reader/feed.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Interact with the Real Python feed""" -# Standard library imports -from typing import Dict, List # noqa - -# Third party imports -import feedparser -import html2text - -# Reader imports -from reader import URL - -_CACHED_FEEDS = dict() # type: Dict[str, feedparser.FeedParserDict] - - -def _feed(url=URL): # type: (str) -> feedparser.FeedParserDict - """Cache contents of the feed, so it's only read once""" - if url not in _CACHED_FEEDS: - _CACHED_FEEDS[url] = feedparser.parse(url) - return _CACHED_FEEDS[url] - - -def get_site(url=URL): # type: (str) -> str - """Get name and link to web site of the feed""" - info = _feed(url).feed - return u"{info.title} ({info.link})".format(info=info) - - -def get_article(article_id, links=False, url=URL): - # type: (str, bool, str) -> str - """Get article from feed with the given ID""" - articles = _feed(url).entries - try: - article = articles[int(article_id)] - except (IndexError, ValueError): - max_id = len(articles) - 1 - msg = "Unknown article ID, use ID from 0 to {}".format(max_id) - raise SystemExit("Error: {}".format(msg)) - - # Get article as HTML - try: - html = article.content[0].value - except AttributeError: - html = article.summary - - # Convert HTML to plain text - to_text = html2text.HTML2Text() - to_text.ignore_links = not links - text = to_text.handle(html) - - return u"# {}\n\n{}".format(article.title, text) - - -def get_titles(url=URL): # type: (str) -> List[str] - """List titles in feed""" - articles = _feed(url).entries - return [a.title for a in articles] diff --git a/reader/viewer.py b/reader/viewer.py deleted file mode 100644 index 1cd6ab8..0000000 --- a/reader/viewer.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Functions for displaying the Real Python feed""" - -# Support Python 2 -from __future__ import print_function - -# Standard library imports -from typing import List # noqa - - -def show(article): # type: (str) -> None - """Show one article""" - print(article) - - -def show_list(site, titles): # type: (str, List[str]) -> None - """Show list of articles""" - print(u"The latest tutorials from {}".format(site)) - for article_id, title in enumerate(titles): - print(u"{:>3} {}".format(article_id, title)) diff --git a/setup.py b/setup.py deleted file mode 100644 index 323eb88..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Setup script for realpython-reader""" - -import os.path -from setuptools import setup - -# The directory containing this file -HERE = os.path.abspath(os.path.dirname(__file__)) - -# The text of the README file -with open(os.path.join(HERE, "README.md")) as fid: - README = fid.read() - -# This call to setup() does all the work -setup( - name="realpython-reader", - version="1.0.0", - description="Read the latest Real Python tutorials", - long_description=README, - long_description_content_type="text/markdown", - url="https://github.com/realpython/reader", - author="Real Python", - author_email="office@realpython.com", - license="MIT", - classifiers=[ - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 3", - ], - packages=["reader"], - include_package_data=True, - install_requires=[ - "feedparser", "html2text", "importlib_resources", "typing" - ], - entry_points={"console_scripts": ["realpython=reader.__main__:main"]}, -) diff --git a/src/reader/__init__.py b/src/reader/__init__.py new file mode 100644 index 0000000..383f287 --- /dev/null +++ b/src/reader/__init__.py @@ -0,0 +1,26 @@ +"""Real Python feed reader. + +Import the `feed` module to work with the Real Python feed: + + >>> from reader import feed + >>> feed.get_titles() + ['Logging in Python', 'The Best Python Books', ...] + +See https://github.com/realpython/reader/ for more information. +""" +# Standard library imports +from importlib import resources + +try: + import tomllib +except ModuleNotFoundError: + # Third party imports + import tomli as tomllib + + +# Version of realpython-reader package +__version__ = "1.1.4" + +# Read URL of the Real Python feed from config file +_cfg = tomllib.loads(resources.read_text("reader", "config.toml")) +URL = _cfg["feed"]["url"] diff --git a/reader/__main__.py b/src/reader/__main__.py similarity index 87% rename from reader/__main__.py rename to src/reader/__main__.py index a75e2a0..56e5fa7 100644 --- a/reader/__main__.py +++ b/src/reader/__main__.py @@ -1,4 +1,4 @@ -"""Read the latest Real Python tutorials +"""Read the latest Real Python tutorials. Usage: ------ @@ -40,26 +40,25 @@ Version: -------- -- realpython-reader v1.0.0 +- realpython-reader v1.1.4 """ # Standard library imports import sys # Reader imports import reader -from reader import feed -from reader import viewer +from reader import feed, viewer -def main(): # type: () -> None - """Read the Real Python article feed""" +def main() -> None: + """Read the Real Python article feed.""" args = [a for a in sys.argv[1:] if not a.startswith("-")] opts = [o for o in sys.argv[1:] if o.startswith("-")] # Show help message if "-h" in opts or "--help" in opts: viewer.show(__doc__) - return + raise SystemExit() # Should links be shown in the text show_links = "-l" in opts or "--show-links" in opts diff --git a/src/reader/config.toml b/src/reader/config.toml new file mode 100644 index 0000000..b77aa11 --- /dev/null +++ b/src/reader/config.toml @@ -0,0 +1,2 @@ +[feed] +url = "https://realpython.com/atom.xml" diff --git a/src/reader/feed.py b/src/reader/feed.py new file mode 100644 index 0000000..16ad7be --- /dev/null +++ b/src/reader/feed.py @@ -0,0 +1,64 @@ +"""Interact with the Real Python feed.""" +# Standard library imports +from typing import Dict, List + +# Third party imports +import feedparser +import html2text + +# Reader imports +from reader import URL + +_CACHED_FEEDS: Dict[str, feedparser.FeedParserDict] = {} + + +def _feed(url: str = URL) -> feedparser.FeedParserDict: + """Cache contents of the feed, so it's only read once.""" + if url not in _CACHED_FEEDS: + _CACHED_FEEDS[url] = feedparser.parse(url) + return _CACHED_FEEDS[url] + + +def get_site(url: str = URL) -> str: + """Get name and link to website of the feed.""" + info = _feed(url) + if exception := info.get("bozo_exception"): + message = f"Could not read feed at {url}" + if "CERTIFICATE_VERIFY_FAILED" in str(exception): + message += ( + ".\n\nYou may need to manually install certificates by running " + "`Install Certificates` in your Python installation folder. " + "See https://realpython.com/installing-python/" + ) + raise SystemExit(message) + return f"{info.feed.title} ({info.feed.link})" + + +def get_article(article_id: str, links: bool = False, url: str = URL) -> str: + """Get article from feed with the given ID.""" + articles = _feed(url).entries + try: + article = articles[int(article_id)] + except (IndexError, ValueError): + max_id = len(articles) - 1 + msg = f"Unknown article ID, use ID from 0 to {max_id}" + raise SystemExit(f"Error: {msg}") + + # Get article as HTML + try: + html = article.content[0].value + except AttributeError: + html = article.summary + + # Convert HTML to plain text + to_text = html2text.HTML2Text() + to_text.ignore_links = not links + text = to_text.handle(html) + + return f"# {article.title}\n\n{text}" + + +def get_titles(url: str = URL) -> List[str]: + """List titles in feed.""" + articles = _feed(url).entries + return [a.title for a in articles] diff --git a/src/reader/viewer.py b/src/reader/viewer.py new file mode 100644 index 0000000..e2852df --- /dev/null +++ b/src/reader/viewer.py @@ -0,0 +1,16 @@ +"""Functions for displaying the Real Python feed.""" + +# Standard library imports +from typing import List + + +def show(article: str) -> None: + """Show one article.""" + print(article) + + +def show_list(site: str, titles: List[str]) -> None: + """Show list of articles.""" + print(f"The latest tutorials from {site}") + for article_id, title in enumerate(titles): + print(f"{article_id:>3} {title}") diff --git a/tests/test_feed.py b/tests/test_feed.py index 7c75bd7..3c9697f 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -1,6 +1,6 @@ -"""Tests for the reader.feed module""" +"""Tests for the reader.feed module.""" # Standard library imports -import os.path +import pathlib # Third party imports import pytest @@ -9,32 +9,32 @@ from reader import feed # Current directory -HERE = os.path.dirname(__file__) +HERE = pathlib.Path(__file__).resolve().parent @pytest.fixture def local_feed(): - """Use local file instead of downloading feed from web""" - return os.path.join(HERE, "realpython_20180919.xml") + """Use local file instead of downloading feed from web.""" + return HERE / "realpython_20180919.xml" @pytest.fixture def local_summary_feed(): - """Use local file instead of downloading feed from web""" - return os.path.join(HERE, "realpython_descriptions_20180919.xml") + """Use local file instead of downloading feed from web.""" + return HERE / "realpython_descriptions_20180919.xml" # # Tests # def test_site(local_feed): - """Test that we can read the site title and link""" + """Test that we can read the site title and link.""" expected = "Real Python (https://realpython.com/)" assert feed.get_site(url=local_feed) == expected def test_article_title(local_feed): - """Test that title is added at top of article""" + """Test that title is added at top of article.""" article_id = 0 title = feed.get_titles(url=local_feed)[article_id] article = feed.get_article(article_id, url=local_feed) @@ -43,7 +43,7 @@ def test_article_title(local_feed): def test_article(local_feed): - """Test that article is returned""" + """Test that article is returned.""" article_id = 2 article_phrases = [ "logging.info('This is an info message')", @@ -57,7 +57,7 @@ def test_article(local_feed): def test_titles(local_feed): - """Test that titles are found""" + """Test that titles are found.""" titles = feed.get_titles(url=local_feed) assert len(titles) == 20 @@ -66,7 +66,7 @@ def test_titles(local_feed): def test_summary(local_summary_feed): - """Test that summary feeds can be read""" + """Test that summary feeds can be read.""" article_id = 1 summary_phrases = [ "Get the inside scoop", @@ -79,7 +79,7 @@ def test_summary(local_summary_feed): def test_invalid_article_id(local_feed): - """Test that invalid article ids are handled gracefully""" + """Test that invalid article ids are handled gracefully.""" article_id = "wrong" with pytest.raises(SystemExit): feed.get_article(article_id, url=local_feed) diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 2bffbb2..b16af7a 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -1,7 +1,4 @@ -"""Tests for the reader.viewer module""" - -# Third party imports -import pytest +"""Tests for the reader.viewer module.""" # Reader imports from reader import viewer @@ -11,7 +8,7 @@ # Tests # def test_show(capsys): - """Test that show adds information to stdout""" + """Test that show adds information to stdout.""" text = "Lorem ipsum dolor sit amet" viewer.show(text) stdout, stderr = capsys.readouterr() @@ -22,7 +19,7 @@ def test_show(capsys): def test_show_list(capsys): - """Test that show_list shows a list of items with an ID""" + """Test that show_list shows a list of items with an ID.""" site = "Real Python" things = ["pathlib", "data classes", "python 3.7", "decorators"] viewer.show_list(site, things)