diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..03e5058 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Test & Publish + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + branches: [master] + +jobs: + # For all PRs and commits to main, run tests for each supported python version + # Note: This is currently expected to fail due to output variations based on + # dependency versions + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -U pip setuptools + pip install -e .[dev] + - name: Run tests + run: pytest -vs tests + + # For git tags, build and publish package to PyPI + publish: + if: ${{ startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build package distributions + run: | + pip install hatch + hatch build + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51857f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# use glob syntax. +syntax: glob + +*.pyc +*~ +*.egg-info +.build +.coverage +.eggs +tmp +.doctrees +dist diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..d8abb5f --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,47 @@ +======================== +The sphinxfeed changelog +======================== + +- 20190315 : Support Python 3 (by using feedgen instead of feedformatter). + feed_description is no longer optional. + +- new config variable ``feed_field_name`` to change the name of the + metadata field to use for specifying the publication date. +- don't publish items whose publication datetime is in the future. +- respect `use_dirhtml` option from `rstgen` when calculating the url +- 20240530 : add support to write + `ATOM `__ instead of RSS. + +- 20240601 : look for two new fields ``category`` and ``tags`` in the `page + metadata + `__ + and if either field or both is present, call the + `feedgen.FeedEntry.category()` method to add ```` elements to the + feed item. The difference between ``category`` and ``tags`` is that the + ``category`` of a blog post may contain whitespace while the ``tags`` metadata + field is a space-separated list of tags, so each tag must be a single word. + Both the category and each tag will become a ```` element in the + feed item. + +- 20240718 : merged 7 commits with minor fixes and config updates from `pull + request suggested by JWCook `__ + +- 20240720 : The message "Skipping %s, publish date is in the future" is now + logged at level INFO instead of WARNING because we don't want the + ``sphinx-build -W`` to fail in this situation. + +- 20240720 : Removed dependency from ``atelier`` because it's easier to call + `subprocess.check_output()` directly here. + +- 20240722 : Support additional timestamp formats (any format supported by + `dateutil `__) + +- 20240728: Add ``feed_entry_permalink`` option to set a permalink GUID for each + feed entry. If a `guid` value is found in the metadata, that will be used; + otherwise, a new one will be generated based on the entry URL. + Defaults to ``False``, in which case the entry URL will be used as a + non-permalink ID. Applies to both Atom and RSS feeds. + +- 20260325: Add `urn:uuid:` prefix to permalink GUIDs so they validate as a full URL. + +- 20260325: Handle trailing slashes in feed_base_url diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e786b4a --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Copyright 2011 Fergus Doyle +Copyright 2016-2024 Rumma & Ko Ltd + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.rst b/README.rst index c835a8f..d9593dc 100644 --- a/README.rst +++ b/README.rst @@ -1,25 +1,140 @@ -sphinxfeed -========== +========================== +The ``sphinxfeed`` package +========================== +.. image:: https://img.shields.io/github/actions/workflow/status/lsaffre/sphinxfeed/build.yml + :alt: Build status + :target: https://github.com/lsaffre/sphinxfeed/actions +.. image:: https://img.shields.io/pypi/v/sphinxfeed-lsaffre?color=blue + :alt: PyPI - package version + :target: https://pypi.org/project/sphinxfeed-lsaffre +.. image:: https://img.shields.io/pypi/pyversions/sphinxfeed-lsaffre + :alt: PyPI - supported python versions + :target: https://pypi.org/project/sphinxfeed-lsaffre -This Sphinx extension is derived from Dan Mackinlay's `sphinxcontrib.feed -`_ package. +This Sphinx extension is a fork of Fergus Doyle's `sphinxfeed package +`__ which itself is derived from Dan +Mackinlay's `sphinxcontrib.feed +`_ package. It +relies on Lars Kiesow's `python-feedgen `__ package +instead of the defunct `feedformatter +`_ package or of Django utils to +generate the feed. -It relies on the `feedformatter `_ -package instead of Django utils to generate the feed. +Features added +============== -Usage ------ +- Support Python 3 (by using feedgen instead of feedformatter). +- Don't publish items having a publication datetime in the future. +- Ability to write + `ATOM `__ instead of RSS. + +- Detect ``category`` and ``tags`` fields in the `page metadata + `__ + and if either or both is present, call the `feedgen.FeedEntry.category()` + method to add ```` elements to the feed item. The difference + between ``category`` and ``tags`` is that the ``category`` of a blog post may + contain whitespace while the ``tags`` metadata field is a space-separated list + of tags, so each tag must be a single word. Both the category and each tag + will become a ```` element in the feed item. + +- Additional Sphinx config variables: + + - ``feed_field_name`` to change the name of the + metadata field to use for specifying the publication date. + + - ``use_dirhtml`` to specify whether `dirhtml` instead of `html` builder is + used when calculating the url + + - ``feed_entry_permalink`` to set a `permalink GUID + `__ + for each feed entry. GUIDs are generated based on the entry URL. Or if a + ``guid`` value is found in page metadata, that will be used instead. For + example, so it can be manually set if the URL changes. Defaults to `False`, + in which case the entry URL will be used as a non-permalink ID. Applies to + both Atom and RSS feeds. + + - ``feed_use_atom`` to generate an Atom feed instead of RSS + + +Installation +============ -#. Install ``sphinxfeed`` using ``easy_install`` / ``pip`` / - ``python setup.py install`` +You can install it using pip:: + + pip install sphinxfeed-lsaffre + +How to test whether the right version of sphinxfeed is installed: + +>>> import sphinxfeed +>>> sphinxfeed.__version__ +'0.3.1' + + +Usage +===== #. Add ``sphinxfeed`` to the list of extensions in your ``conf.py``:: - + extensions = [..., 'sphinxfeed'] -#. Customise the necessary configuration options to correctly generate the - feed:: +#. Customise the necessary configuration options to correctly generate + the feed:: - feed_base_url = 'http://YOUR_HOST_URL' + feed_base_url = 'https://YOUR_HOST_URL' feed_author = 'YOUR NAME' + feed_description = "A longer description" + + # optional options + feed_field_name = 'date' # default value is "Publish Date" + feed_use_atom = False + use_dirhtml = False + +#. Optionally use the following metadata fields: + + - date (or any other name configured using feed_field_name) + - author + - guid + - tags + - category + +#. Sphinxfeed will include only `.rst` files that have a ``:date:`` field with a + date that does not lie in the future. + + +Maintenance +=========== + +See also the files `LICENSE` and `CHANGELOG.rst`. + +Install a developer version:: + + git clone https://github.com/lsaffre/sphinxfeed.git + pip install -e ".[dev]" + +Run the test suite:: + + $ pytest + +Generate an HTML test coverage report:: + + $ pytest --cov-report=html + $ python -m webbrowser test-reports/index.html + +Release a new version to PyPI:: + + $ hatch version micro + $ git commit -am "release to pypi" + $ git tag v$(hatch version) + $ git push --tags + +See `Hatch Versioning `__. and `Publishing +to PyPI with a Trusted Publisher `__. + +Manually release to PyPI using your machine and token:: + + $ hatch build + $ twine check --strict dist/* + $ twine upload dist/* +The ``twine upload`` step requires authentication credentials in your +`~/.pypirc` file. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..20e403f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sphinxfeed-lsaffre" +dynamic = ["version"] +description = "Sphinx extension for generating RSS feeds" +readme = "README.rst" +license = {file = "LICENSE"} +requires-python = ">= 3.8" + +authors = [ + { name = "Fergus Doyle", email = "fergus.doyle@largeblue.com" }, + { name = "Luc Saffre", email = "luc@saffre-rumma.net" }, + { name = "Jordan Cook", email = "jordan.cook.git@proton.me" }, +] + +maintainers = [ + { name = "Luc Saffre", email = "luc@saffre-rumma.net" }, +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Documentation", + "Topic :: Utilities", +] + +dependencies = [ + "feedgen", + "python-dateutil", + "Sphinx", +] + +[project.optional-dependencies] +dev = [ + "hatch", + "pytest", + "pytest-cov", +] + +[project.urls] +homepage = "https://github.com/lsaffre/sphinxfeed" +issues = "https://github.com/lsaffre/sphinxfeed/issues" + +[tool.hatch.version] +path = "sphinxfeed.py" + +[tool.hatch.build.targets.sdist] +include = ["sphinxfeed.py"] + +[tool.hatch.build.targets.wheel] +only-include = ["sphinxfeed.py"] + +[tool.hatch.build] +dev-mode-dirs = ["."] + +# Coverage report config: by default, show condensed terminal output +[tool.coverage.run] +branch = true +source = ['.'] +omit = ['tests/*', 'tasks.py'] + +[tool.coverage.html] +directory = 'test-reports' + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=term" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py deleted file mode 100644 index b63b2d5..0000000 --- a/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -from distutils.core import setup - -long_desc = open('README.rst').read() - -requires = [ - 'Sphinx>=0.6', - 'feedformatter', - ] - -setup( - name='sphinxfeed', - version='0.3', - license='BSD', - author='junkafarian', - author_email='junkafarian@gmail.com', - url='https://github.com/junkafarian/sphinxfeed', - description='Sphinx extension for generating RSS feeds', - long_description=long_desc, - classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Console', - 'Environment :: Web Environment', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Topic :: Documentation', - 'Topic :: Utilities', - ], - platforms='any', - py_modules=['sphinxfeed'], - include_package_data=True, - install_requires=requires, -) diff --git a/sphinxfeed.py b/sphinxfeed.py index 2e8d202..46a736d 100644 --- a/sphinxfeed.py +++ b/sphinxfeed.py @@ -1,7 +1,24 @@ # This application is derived from Dan Mackinlay's sphinxcontrib.feed package. # The original can be found at http://bitbucket.org/birkenfeld/sphinx-contrib/src/tip/feed/ +""" +See https://github.com/lsaffre/sphinxfeed +""" + +__version__ = '0.3.6' + +import os.path +from datetime import datetime +from uuid import NAMESPACE_URL, uuid5 + +from dateutil.parser import parse as parse_date +from dateutil.tz import tzlocal +from feedgen.feed import FeedEntry, FeedGenerator +from sphinx.util.logging import getLogger + + +doc_trees = [] # for atelier +logger = getLogger(__name__) -import unittest def setup(app): """ see: http://sphinx.pocoo.org/ext/appapi.html @@ -9,99 +26,114 @@ def setup(app): """ from sphinx.application import Sphinx if not isinstance(app, Sphinx): return - app.add_config_value('feed_base_url', '', '') - app.add_config_value('feed_description', '', '') - app.add_config_value('feed_author', '', '') + app.add_config_value('feed_base_url', '', 'html') + app.add_config_value('feed_description', '', 'html') + app.add_config_value('feed_author', '', 'html') + app.add_config_value('feed_field_name', 'Publish Date', 'env') app.add_config_value('feed_filename', 'rss.xml', 'html') - + app.add_config_value('feed_entry_permalink', False, 'html') + app.add_config_value('feed_use_atom', False, 'html') + app.add_config_value('use_dirhtml', False, 'html') + app.connect('html-page-context', create_feed_item) app.connect('build-finished', emit_feed) app.connect('builder-inited', create_feed_container) - + #env.process_metadata deletes most of the docinfo, and dates #in particular. + def create_feed_container(app): - from feedformatter import Feed - feed = Feed() - feed.feed['title'] = app.config.project - feed.feed['link'] = app.config.feed_base_url - feed.feed['author'] = app.config.feed_author - feed.feed['description'] = app.config.feed_description - + feed = FeedGenerator() + feed.title(app.config.project) + feed.link(href=app.config.feed_base_url) + if app.config.feed_use_atom: + feed.id(app.config.feed_base_url) + base_url = app.config.feed_base_url.rstrip('/') + feed.link(href=base_url + '/' + app.config.feed_filename, rel='self') + feed.author({'name': app.config.feed_author}) + feed.description(app.config.feed_description) + if app.config.language: - feed.feed['language'] = app.config.language + feed.language(app.config.language) if app.config.copyright: - feed.feed['copyright'] = app.config.copyright + feed.copyright(app.config.copyright) app.builder.env.feed_feed = feed if not hasattr(app.builder.env, 'feed_items'): app.builder.env.feed_items = {} + def create_feed_item(app, pagename, templatename, ctx, doctree): """ Here we have access to nice HTML fragments to use in, say, an RSS feed. """ - import time - def parse_pubdate(pubdate): - try: - date = time.strptime(pubdate, '%Y-%m-%d %H:%M') - except ValueError: - date = time.strptime(pubdate, '%Y-%m-%d') - return date - + env = app.builder.env metadata = app.builder.env.metadata.get(pagename, {}) - - if 'Publish Date' not in metadata: - """ Don't index dateless articles. - Use the metadata syntax in order to specify the publish data:: - - :Publish Date: 2010-01-01 - """ - return - - item = { - 'title': ctx.get('title'), - 'link': app.config.feed_base_url + '/' + ctx['current_page_name'] + ctx['file_suffix'], - 'description': ctx.get('body'), - 'pubDate': parse_pubdate(metadata['Publish Date']) - } - if 'author' in metadata: - item['author'] = metadata['author'] + + pubdate = metadata.get(app.config.feed_field_name, None) + if not pubdate: + return + + # Default to local timezone, unless one is explicitly provided + pubdate = parse_date(pubdate) + if not pubdate.tzinfo: + pubdate = pubdate.replace(tzinfo=tzlocal()) + if pubdate > datetime.now(tzlocal()): + logger.info("Skipping %s, publish date is in the future: %s", pagename, pubdate) + return + + if not ctx.get('body') or not ctx.get('title'): + return + + item = FeedEntry() + item.title(ctx.get('title')) + base_url = app.config.feed_base_url.rstrip('/') + href = base_url + '/' + ctx['current_page_name'] + if not app.config.use_dirhtml: + href += ctx['file_suffix'] + item.link(href=href) + item.description(ctx.get('body')) + item.published(pubdate) + + # Entry ID option 1: Use a GUID as a permalink. Also sets item.id for Atom. + if app.config.feed_entry_permalink: + if not (guid := metadata.get('guid')): + guid = uuid5(NAMESPACE_URL, href) + item.guid(f'urn:uuid:{guid}', permalink=True) + # Entry ID option 2: Use the URL as the ID. Also sets item.guid (non-permalink) for RSS. + else: + item.id(href) + + if author := metadata.get('author'): + # author may be a str (in field list/frontmatter) or a dict (expected by feedgen) + if isinstance (author, str): + author = {'name': author} + item.author(author) + if cat := metadata.get("category", None): + item.category(term=cat) + if tags := metadata.get("tags", None): + # tags may be a str (in field list/frontmatter), or a list (from sphinx-tags extension) + if isinstance(tags, str): + tags = tags.split() + for tag in tags: + item.category(term=tag) + env.feed_items[pagename] = item + #Additionally, we might like to provide our templates with a way to link to the rss output file - ctx['rss_link'] = app.config.feed_base_url + '/' + app.config.feed_filename - + ctx['rss_link'] = base_url + '/' + app.config.feed_filename + + def emit_feed(app, exc): - import os.path - ordered_items = app.builder.env.feed_items.values() + ordered_items = list(app.builder.env.feed_items.values()) feed = app.builder.env.feed_feed - ordered_items.sort( - cmp=lambda x,y: cmp(x['pubDate'],y['pubDate']), - reverse=True) + ordered_items.sort(key=lambda x: x.published()) for item in ordered_items: - feed.items.append(item) - - path = os.path.join(app.builder.outdir, - app.config.feed_filename) - feed.format_rss2_file(path) - - from os import path - from sphinx.application import ENV_PICKLE_FILENAME - from sphinx.util.console import bold - # save the environment - builder = app.builder - builder.info(bold('pickling environment... '), nonl=True) - builder.env.topickle(path.join(builder.doctreedir, ENV_PICKLE_FILENAME)) - builder.info('done') - - # global actions - builder.info(bold('checking consistency... '), nonl=True) - builder.env.check_consistency() - builder.info('done') - -## Tests - -# ... TODO - -if __name__ == '__main__': - unittest.main() + feed.add_entry(item) # prepends the item + + path = os.path.join(app.builder.outdir, app.config.feed_filename) + + if app.config.feed_use_atom: + feed.atom_file(path) + else: + feed.rss_file(path) diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..72f45eb --- /dev/null +++ b/tasks.py @@ -0,0 +1,10 @@ +from atelier.invlib import setup_from_tasks + +ns = setup_from_tasks( + globals(), + "sphinxfeed", + blogref_url="https://luc.lino-framework.org", + revision_control_system='git', + test_command="pytest tests", # more options in file pytest.ini + # tolerate_sphinx_warnings=True, + cleanable_files=[]) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e7d9b65 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +"""Pytest config for testing with SphinxTestApp + +Notes on testing setup: +* A Sphinx test app is provided via `app` fixture from `sphinx.testing.fixtures`. +* The `sources` dir contains source files and config to use for tests + * Set via the `rootdir` fixture +* A subdirectory to use for a specific test can be set by a pytest marker: + * `@pytest.mark.sphinx("html", testroot="...") + * This subdirectory must contain a conf.py and source files +* The `outputs` dir contains expected output files +* Test build output is located under `/tmp/pytest*` +""" + +import shutil +from pathlib import Path + +import pytest + +collect_ignore = ["sources", "outputs"] +pytest_plugins = "sphinx.testing.fixtures" + +OUTPUT_DIR = Path(__file__).parent.resolve() / "outputs" +SOURCE_DIR = Path(__file__).parent.resolve() / "sources" + + +@pytest.fixture(scope="session") +def rootdir(): + """This fixture overrides the root directory used by SphinxTestApp. Also patches in a + Path.copytree() method for compatibility with sphinx.testing.path.path in older Sphinx versions. + """ + + class PatchedPath(type(Path())): + def __new__(cls, *args): + return super().__new__(cls, *args) + + def copytree(src, dest): + shutil.copytree(src, dest, symlinks=True) + + yield PatchedPath(SOURCE_DIR) diff --git a/tests/outputs/atom-permalink.xml b/tests/outputs/atom-permalink.xml new file mode 100644 index 0000000..4ac5a99 --- /dev/null +++ b/tests/outputs/atom-permalink.xml @@ -0,0 +1,49 @@ + + + http://news.example.com/ + First sphinxfeed tester + 2024-07-21T18:58:02.430522+00:00 + + Joe Dow + + + python-feedgen + 2018 Joe Doe + Joe's blog + + urn:uuid:ffc3948d-d699-5ad1-89b9-424f6c9a34aa + Second day + 2024-07-21T18:58:02.552860+00:00 + + Joe + + + <section id="second-day"> + <h1>Second day<a class="headerlink" href="#second-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, + Terese teisipäev,</p> + </section> + + + 2018-03-12T23:30:00+00:00 + + + urn:uuid:b76ad247-511f-5384-9d51-1a20a02ffd46 + First day + 2024-07-21T18:58:02.488103+00:00 + + <section id="first-day"> + <h1>First day<a class="headerlink" href="#first-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, …</p> + </section> + + + 2018-03-11T00:00:00+00:00 + + diff --git a/tests/outputs/atom.xml b/tests/outputs/atom.xml new file mode 100644 index 0000000..4d54e2d --- /dev/null +++ b/tests/outputs/atom.xml @@ -0,0 +1,49 @@ + + + http://news.example.com/ + First sphinxfeed tester + 2024-07-21T18:58:02.430522+00:00 + + Joe Dow + + + python-feedgen + 2018 Joe Doe + Joe's blog + + http://news.example.com/second + Second day + 2024-07-21T18:58:02.552860+00:00 + + Joe + + + <section id="second-day"> + <h1>Second day<a class="headerlink" href="#second-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, + Terese teisipäev,</p> + </section> + + + 2018-03-12T23:30:00+00:00 + + + http://news.example.com/first + First day + 2024-07-21T18:58:02.488103+00:00 + + <section id="first-day"> + <h1>First day<a class="headerlink" href="#first-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, …</p> + </section> + + + 2018-03-11T00:00:00+00:00 + + diff --git a/tests/outputs/rss-permalink.xml b/tests/outputs/rss-permalink.xml new file mode 100644 index 0000000..1323796 --- /dev/null +++ b/tests/outputs/rss-permalink.xml @@ -0,0 +1,45 @@ + + + + First sphinxfeed tester + http://news.example.com/ + Joe's blog + 2018 Joe Doe + http://www.rssboard.org/rss-specification + python-feedgen + en + Sun, 21 Jul 2024 18:18:18 +0000 + + Second day + http://news.example.com/second.html + urn:uuid:65a78116-5715-4f78-bbd4-384a018c99f9 + + <section id="second-day"> + <h1>Second day<a class="headerlink" href="#second-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, + Terese teisipäev,</p> + </section> + + Mon, 12 Mar 2018 23:30:00 +0000 + + + First day + http://news.example.com/first.html + urn:uuid:f21329fa-c178-5550-9bbd-8cd2f591844a + + <section id="first-day"> + <h1>First day<a class="headerlink" href="#first-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, …</p> + </section> + + Sun, 11 Mar 2018 00:00:00 +0000 + + + diff --git a/tests/outputs/rss.xml b/tests/outputs/rss.xml new file mode 100644 index 0000000..eafe7c3 --- /dev/null +++ b/tests/outputs/rss.xml @@ -0,0 +1,45 @@ + + + + First sphinxfeed tester + http://news.example.com/ + Joe's blog + 2018 Joe Doe + http://www.rssboard.org/rss-specification + python-feedgen + en + Sun, 21 Jul 2024 18:18:18 +0000 + + Second day + http://news.example.com/second.html + + <section id="second-day"> + <h1>Second day<a class="headerlink" href="#second-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, + Terese teisipäev,</p> + </section> + + http://news.example.com/second.html + Mon, 12 Mar 2018 23:30:00 +0000 + + + First day + http://news.example.com/first.html + + <section id="first-day"> + <h1>First day<a class="headerlink" href="#first-day" title="Link to this heading">¶</a></h1> + <p>Mul seitse pruuti on, + neid kõiki armastan + ja iga päev neist üht mina külastan. + Ikka Emma esmaspäev, …</p> + </section> + + http://news.example.com/first.html + Sun, 11 Mar 2018 00:00:00 +0000 + + + diff --git a/tests/sources/test-atom/conf.py b/tests/sources/test-atom/conf.py new file mode 100644 index 0000000..7a620cd --- /dev/null +++ b/tests/sources/test-atom/conf.py @@ -0,0 +1,20 @@ +# General sphinx config +source_suffix = '.rst' +master_doc = 'index' +project = "First sphinxfeed tester" +copyright = '2018 Joe Doe' +language = 'en' +html_title = "Joe's website" +html_short_title = u"Home" +html_last_updated_fmt = '%Y-%m-%d' +use_dirhtml = True + +# Sphinxfeed config +extensions = ['sphinxfeed'] +feed_base_url = 'http://news.example.com/' +feed_author = 'Joe Dow' +feed_title = "Joe's blog" +feed_field_name = 'date' +feed_description = "Joe's blog" +feed_filename = 'atom.xml' +feed_use_atom = True diff --git a/tests/sources/test-atom/empty.rst b/tests/sources/test-atom/empty.rst new file mode 100644 index 0000000..7ccaed8 --- /dev/null +++ b/tests/sources/test-atom/empty.rst @@ -0,0 +1,4 @@ +:date: 2018-03-11 +:orphan: + +.. missing title; should be ignored diff --git a/tests/sources/test-atom/first.rst b/tests/sources/test-atom/first.rst new file mode 100644 index 0000000..c6a1a55 --- /dev/null +++ b/tests/sources/test-atom/first.rst @@ -0,0 +1,11 @@ +:date: 2018-03-11 +:category: test + +========= +First day +========= + +Mul seitse pruuti on, +neid kõiki armastan +ja iga päev neist üht mina külastan. +Ikka Emma esmaspäev, ... diff --git a/tests/sources/test-atom/future.rst b/tests/sources/test-atom/future.rst new file mode 100644 index 0000000..cb36fd6 --- /dev/null +++ b/tests/sources/test-atom/future.rst @@ -0,0 +1,7 @@ +:date: 3030-03-03T03:03:03Z + +=================== +Future post (draft) +=================== + +This post should not be published yet! \ No newline at end of file diff --git a/tests/sources/test-atom/index.rst b/tests/sources/test-atom/index.rst new file mode 100644 index 0000000..3d1fd13 --- /dev/null +++ b/tests/sources/test-atom/index.rst @@ -0,0 +1,14 @@ +========== +Joe's blog +========== + + +Sitemap +------- + +.. toctree:: + :maxdepth: 1 + + first + second + future diff --git a/tests/sources/test-atom/second.rst b/tests/sources/test-atom/second.rst new file mode 100644 index 0000000..f66c746 --- /dev/null +++ b/tests/sources/test-atom/second.rst @@ -0,0 +1,14 @@ +:date: March 12 2018, 11:30 PM UTC +:author: Joe +:tags: poetry, unit-test + +========== +Second day +========== + + +Mul seitse pruuti on, +neid kõiki armastan +ja iga päev neist üht mina külastan. +Ikka Emma esmaspäev, +Terese teisipäev, diff --git a/tests/sources/test-rss/conf.py b/tests/sources/test-rss/conf.py new file mode 100644 index 0000000..74a1bbf --- /dev/null +++ b/tests/sources/test-rss/conf.py @@ -0,0 +1,19 @@ +# General sphinx config +source_suffix = '.rst' +master_doc = 'index' +project = "First sphinxfeed tester" +copyright = '2018 Joe Doe' +language = 'en' +html_title = "Joe's website" +html_short_title = u"Home" +html_last_updated_fmt = '%Y-%m-%d' + +# Sphinxfeed config +extensions = ['sphinxfeed'] +feed_base_url = 'http://news.example.com/' +feed_author = 'Joe Dow' +feed_title = "Joe's blog" +feed_field_name = 'date' +feed_description = "Joe's blog" +feed_filename = 'rss.xml' +feed_use_atom = False diff --git a/tests/sources/test-rss/empty.rst b/tests/sources/test-rss/empty.rst new file mode 100644 index 0000000..cb890a6 --- /dev/null +++ b/tests/sources/test-rss/empty.rst @@ -0,0 +1,4 @@ +:date: 2018-03-11 +:orphan: + +.. missing body; should be ignored diff --git a/tests/sources/test-rss/first.rst b/tests/sources/test-rss/first.rst new file mode 100644 index 0000000..c6a1a55 --- /dev/null +++ b/tests/sources/test-rss/first.rst @@ -0,0 +1,11 @@ +:date: 2018-03-11 +:category: test + +========= +First day +========= + +Mul seitse pruuti on, +neid kõiki armastan +ja iga päev neist üht mina külastan. +Ikka Emma esmaspäev, ... diff --git a/tests/sources/test-rss/future.rst b/tests/sources/test-rss/future.rst new file mode 100644 index 0000000..cb36fd6 --- /dev/null +++ b/tests/sources/test-rss/future.rst @@ -0,0 +1,7 @@ +:date: 3030-03-03T03:03:03Z + +=================== +Future post (draft) +=================== + +This post should not be published yet! \ No newline at end of file diff --git a/tests/sources/test-rss/index.rst b/tests/sources/test-rss/index.rst new file mode 100644 index 0000000..3d1fd13 --- /dev/null +++ b/tests/sources/test-rss/index.rst @@ -0,0 +1,14 @@ +========== +Joe's blog +========== + + +Sitemap +------- + +.. toctree:: + :maxdepth: 1 + + first + second + future diff --git a/tests/sources/test-rss/second.rst b/tests/sources/test-rss/second.rst new file mode 100644 index 0000000..75da6d8 --- /dev/null +++ b/tests/sources/test-rss/second.rst @@ -0,0 +1,15 @@ +:date: March 12 2018, 11:30 PM UTC +:author: Joe +:tags: poetry, unit-test +:guid: 65a78116-5715-4f78-bbd4-384a018c99f9 + +========== +Second day +========== + + +Mul seitse pruuti on, +neid kõiki armastan +ja iga päev neist üht mina külastan. +Ikka Emma esmaspäev, +Terese teisipäev, diff --git a/tests/test_sphinxfeed.py b/tests/test_sphinxfeed.py new file mode 100644 index 0000000..86a6d2c --- /dev/null +++ b/tests/test_sphinxfeed.py @@ -0,0 +1,153 @@ +# Copyright 2018-2024 Rumma & Ko Ltd +""" +Runs sphinx builds using SphinxTestApp to generate RSS and Atom feeds, and compares them to expected +output one element at a time. Shows detailed output on failure. +""" +from io import StringIO +from pathlib import Path +from textwrap import dedent +from unittest.mock import patch +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +import pytest +from dateutil.tz import UTC +from sphinx.testing.util import SphinxTestApp + +from tests.conftest import OUTPUT_DIR + +RSS_META_ATTRIBUTES = [ + "copyright", + "description", + "docs", + "generator", + "language", + "link", + "title", +] +RSS_ITEM_ATTRIBUTES = [ + "guid", + "title", + "link", + "description", + "pubDate", +] + +ATOM_SCHEMA = "http://www.w3.org/2005/Atom" +ATOM_META_ATTRIBUTES = [ + "id", + "author/name", + "generator", + "link", + "rights", + "title", +] +ATOM_ITEM_ATTRIBUTES = [ + "id", + "content", + "link", + "published", + "title", +] + + +@pytest.mark.parametrize( + "permalink,expected_file", + [(False, "rss.xml"), (True, "rss-permalink.xml")], +) +@pytest.mark.sphinx("html", testroot="rss") +@patch("sphinxfeed.tzlocal", return_value=UTC) +def test_build_rss(mock_tzlocal, permalink: bool, expected_file: str, app: SphinxTestApp, status: StringIO): + app.config.feed_entry_permalink = permalink + app.build(force_all=True) + assert "build succeeded" in status.getvalue() + + build_dir = Path(app.srcdir) / "_build" / "html" + _compare_rss_feeds((build_dir / "rss.xml"), (OUTPUT_DIR / expected_file)) + + +def _compare_rss_feeds(file_1: Path, file_2: Path): + """Compare XML contents of two RSS feeds, ignoring formatting, whitespace, and build date.""" + feed_contents_1 = _parse_xml(file_1).find("channel") + feed_contents_2 = _parse_xml(file_2).find("channel") + + # compare metadata + for attr in RSS_META_ATTRIBUTES: + _compare_attrs(attr, feed_contents_1, feed_contents_2) + + # Compare all feed items + feed_items_1 = feed_contents_1.findall("item") + feed_items_2 = feed_contents_2.findall("item") + assert len(feed_items_1) == len(feed_items_2) + for item_1, item_2 in zip(feed_items_1, feed_items_2): + for attr in RSS_ITEM_ATTRIBUTES: + _compare_attrs(attr, item_1, item_2) + + +@pytest.mark.parametrize( + "permalink,expected_file", + [(False, "atom.xml"), (True, "atom-permalink.xml")], +) +@pytest.mark.sphinx("html", testroot="atom") +@patch("sphinxfeed.tzlocal", return_value=UTC) +def test_build_atom(mock_tzlocal, permalink: bool, expected_file: str, app: SphinxTestApp, status: StringIO): + app.config.feed_entry_permalink = permalink + app.build(force_all=True) + assert "build succeeded" in status.getvalue() + + build_dir = Path(app.srcdir) / "_build" / "html" + _compare_atom_feeds((build_dir / "atom.xml"), (OUTPUT_DIR / expected_file)) + + +def _compare_atom_feeds(file_1: Path, file_2: Path): + """Compare XML contents of two Atom feeds, ignoring formatting, whitespace, and build date.""" + feed_contents_1 = _parse_xml(file_1) + feed_contents_2 = _parse_xml(file_2) + + # compare metadata + for attr in ATOM_META_ATTRIBUTES: + _compare_attrs(attr, feed_contents_1, feed_contents_2, atom=True) + + # Compare all feed items + feed_items_1 = feed_contents_1.findall(f"{{{ATOM_SCHEMA}}}entry") + feed_items_2 = feed_contents_2.findall(f"{{{ATOM_SCHEMA}}}entry") + assert len(feed_items_1) == len(feed_items_2) + for entry_1, entry_2 in zip(feed_items_1, feed_items_2): + for attr in ATOM_ITEM_ATTRIBUTES: + _compare_attrs(attr, entry_1, entry_2, atom=True) + + +def _parse_xml(file: Path): + return ElementTree.fromstring(file.read_text()) + + +def _compare_attrs(attr: str, e1: Element, e2: Element, atom: bool = False): + """Compare attribute values in two XML elements, handle variations in formatting, and print + comparison to show on test failure. + """ + print(f"[{attr}]:") + + # For Atom feeds, we need to append the Atom schema to the attribute name + if atom: + if attr == "author/name": + attr = f"{{{ATOM_SCHEMA}}}author/{{{ATOM_SCHEMA}}}name" + else: + attr = f"{{{ATOM_SCHEMA}}}{attr}" + + # Handle one or both values missing + if (val_1 := e1.find(attr)) is None or (val_2 := e2.find(attr)) is None: + raise ValueError(f"Attribute {attr} missing") + # Handle link attribute + if atom and attr.endswith("link"): + text_1 = val_1.attrib["href"] + text_2 = val_2.attrib["href"] + # Handle whitespace differences in HTML content + else: + text_1 = dedent(val_1.text).strip() + text_2 = dedent(val_2.text).strip() + # Handle different phrasing in Sphinx <=7.1 + text_1 = text_1.replace('Permalink', 'Link') + text_2 = text_2.replace('Permalink', 'Link') + + print(f" expected: {text_1}\n actual: {text_2}") + assert text_1 == text_2