diff --git a/.bumpversion.cfg b/.bumpversion.cfg deleted file mode 100644 index 2f7c250..0000000 --- a/.bumpversion.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[bumpversion] -current_version = 0.1.0 -commit = False -tag = False - -[bumpversion:file:reader/__init__.py] - -[bumpversion:file:setup.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 new file mode 100644 index 0000000..83ce77d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include src/reader/*.toml diff --git a/README.md b/README.md index 90e8c88..1a0111d 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,58 @@ The Real Python Feed Reader is a basic [web feed](https://en.wikipedia.org/wiki/Web_feed) reader that can download the latest Real Python tutorials from the [Real Python feed](https://realpython.com/contact/#rss-atom-feed). +For more information see the tutorial [How to Publish an Open-Source Python Package to PyPI](https://realpython.com/pypi-publish-python-package/) on Real Python. + ## Installation 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/) - 0 Splitting, Concatenating, and Joining Strings in Python - 1 Image Segmentation Using Color Spaces in OpenCV + Python - 2 Python Community Interview With Mahdi Yusuf - 3 Absolute vs Relative Imports in Python - 4 Top 10 Must-Watch PyCon Talks - 5 Logging in Python - 6 The Best Python Books - 7 Conditional Statements in Python - 8 Structuring Python Programs - 9 We're Celebrating 1 Million Page Views per Month! - 10 Python Pandas: Tricks & Features You May Not Know - 11 Python Community Interview With Mariatta Wijaya - 12 Primer on Python Decorators - 13 Sets in Python - 14 The Ultimate Guide to Django Redirects - 15 Advanced Git Tips for Python Developers - 16 Python Community Interview With Mike Driscoll - 17 Dictionaries in Python - 18 Socket Programming in Python (Guide) - 19 Python Code Quality: Tools & Best Practices + 0 How to Publish an Open-Source Python Package to PyPI + 1 Python "while" Loops (Indefinite Iteration) + 2 Writing Comments in Python (Guide) + 3 Setting Up Python for Machine Learning on Windows + 4 Python Community Interview With Michael Kennedy + 5 Practical Text Classification With Python and Keras + 6 Getting Started With Testing in Python + 7 Python, Boto3, and AWS S3: Demystified + 8 Python's range() Function (Guide) + 9 Python Community Interview With Mike Grouchy + 10 How to Round Numbers in Python + 11 Building and Documenting Python REST APIs With Flask and Connexion – Part 2 + 12 Splitting, Concatenating, and Joining Strings in Python + 13 Image Segmentation Using Color Spaces in OpenCV + Python + 14 Python Community Interview With Mahdi Yusuf + 15 Absolute vs Relative Imports in Python + 16 Top 10 Must-Watch PyCon Talks + 17 Logging in Python + 18 The Best Python Books + 19 Conditional Statements in Python To read one particular tutorial, call the program with the numerical ID of the tutorial as a parameter: $ realpython 0 - # Splitting, Concatenating, and Joining Strings in Python + # How to Publish an Open-Source Python Package to PyPI - There are few guarantees in life: death, taxes, and programmers needing to - deal with strings. Strings can come in many forms. They could be unstructured - text, usernames, product descriptions, database column names, or really - anything else that we describe using language. + Python is famous for coming with batteries included. Sophisticated + capabilities are available in the standard library. You can find modules for + working with sockets, parsing CSV, JSON, and XML files, and working with + files and file paths. - With the near-ubiquity of string data, it's important to master the tools of - the trade when it comes to strings. Luckily, Python makes string manipulation - very simple, especially when compared to other languages and even older - versions of Python. + However great the packages included with Python are, there are many + fantastic projects available outside the standard library. These are most + often hosted at the Python Packaging Index (PyPI), historically known as the + Cheese Shop. At PyPI, you can find everything from Hello World to advanced + deep learning libraries. [... The full text of the article ...] @@ -58,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 1e498c8..0000000 --- a/reader/__init__.py +++ /dev/null @@ -1,15 +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 -""" -# Version of realpython-reader package -__version__ = "0.1.0" - -# URL of Real Python feed -URL = "https://realpython.com/atom.xml" diff --git a/reader/feed.py b/reader/feed.py deleted file mode 100644 index a566da1..0000000 --- a/reader/feed.py +++ /dev/null @@ -1,55 +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(): # type: () -> 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(): # type: () -> str - """Get name and link to web site of the feed""" - info = _feed().feed - return "{info.title} ({info.link})".format(info=info) - - -def get_article(article_id, links=False): # type: (str, bool) -> str - """Get article from feed with the given ID""" - articles = _feed().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(): # type: () -> List[str] - """List titles in feed""" - articles = _feed().entries - return [a.title for a in articles] diff --git a/reader/viewer.py b/reader/viewer.py deleted file mode 100644 index 33b578b..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("The latest tutorials from {}".format(site)) - for article_id, title in enumerate(titles): - print("{:>3} {}".format(article_id, title)) diff --git a/setup.py b/setup.py deleted file mode 100644 index 8e17548..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Setup script for realpython-reader""" - -import pathlib -from setuptools import setup - -# The directory containing this file -HERE = pathlib.Path(__file__).parent - -# The text of the README file -README = (HERE / "README.md").read_text() - - -# This call to setup() does all the work -setup( - name="realpython-reader", - version="0.1.0", - description="Read 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"], - install_requires=["feedparser", "html2text", "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 72% rename from reader/__main__.py rename to src/reader/__main__.py index 62b8ae0..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: ------ @@ -35,38 +35,47 @@ - https://pypi.org/project/realpython-reader/ - https://github.com/realpython/reader + + +Version: +-------- + +- realpython-reader v1.1.4 """ # Standard library imports import sys # Reader imports -from reader import feed -from reader import viewer +import reader +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 + # Get URL from config file + url = reader.URL + # An article ID is given, show article if args: for article_id in args: - article = feed.get_article(article_id, show_links) + article = feed.get_article(article_id, links=show_links, url=url) viewer.show(article) # No ID is given, show list of articles else: - site = feed.get_site() - titles = feed.get_titles() + site = feed.get_site(url=url) + titles = feed.get_titles(url=url) viewer.show_list(site, titles) 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 513359e..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,81 +9,77 @@ from reader import feed # Current directory -HERE = os.path.dirname(__file__) +HERE = pathlib.Path(__file__).resolve().parent @pytest.fixture -def monkeypatch_feed(monkeypatch): - """Use local file instead of downloading feed from web""" - local_path = os.path.join(HERE, "realpython_20180919.xml") - monkeypatch.setattr(feed, "URL", local_path) - return local_path +def local_feed(): + """Use local file instead of downloading feed from web.""" + return HERE / "realpython_20180919.xml" @pytest.fixture -def monkeypatch_summary_feed(monkeypatch): - """Use local file instead of downloading feed from web""" - local_path = os.path.join(HERE, "realpython_descriptions_20180919.xml") - monkeypatch.setattr(feed, "URL", local_path) - return local_path +def local_summary_feed(): + """Use local file instead of downloading feed from web.""" + return HERE / "realpython_descriptions_20180919.xml" # # Tests # -def test_site(monkeypatch_feed): - """Test that we can read the site title and link""" +def test_site(local_feed): + """Test that we can read the site title and link.""" expected = "Real Python (https://realpython.com/)" - assert feed.get_site() == expected + assert feed.get_site(url=local_feed) == expected -def test_article_title(monkeypatch_feed): - """Test that title is added at top of article""" +def test_article_title(local_feed): + """Test that title is added at top of article.""" article_id = 0 - title = feed.get_titles()[article_id] - article = feed.get_article(article_id) + title = feed.get_titles(url=local_feed)[article_id] + article = feed.get_article(article_id, url=local_feed) assert article.strip("# ").startswith(title) -def test_article(monkeypatch_feed): - """Test that article is returned""" +def test_article(local_feed): + """Test that article is returned.""" article_id = 2 article_phrases = [ "logging.info('This is an info message')", "By using the `level` parameter", " * `level`: The root logger", ] - article = feed.get_article(article_id) + article = feed.get_article(article_id, url=local_feed) for phrase in article_phrases: assert phrase in article -def test_titles(monkeypatch_feed): - """Test that titles are found""" - titles = feed.get_titles() +def test_titles(local_feed): + """Test that titles are found.""" + titles = feed.get_titles(url=local_feed) assert len(titles) == 20 assert titles[0] == "Absolute vs Relative Imports in Python" assert titles[9] == "Primer on Python Decorators" -def test_summary(monkeypatch_summary_feed): - """Test that summary feeds can be read""" +def test_summary(local_summary_feed): + """Test that summary feeds can be read.""" article_id = 1 summary_phrases = [ "Get the inside scoop", "this list of\ninformative videos", ] - summary = feed.get_article(article_id) + summary = feed.get_article(article_id, url=local_summary_feed) for phrase in summary_phrases: assert phrase in summary -def test_invalid_article_id(monkeypatch_feed): - """Test that invalid article ids are handled gracefully""" +def test_invalid_article_id(local_feed): + """Test that invalid article ids are handled gracefully.""" article_id = "wrong" with pytest.raises(SystemExit): - feed.get_article(article_id) + 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)