From 62300b81cf7687d176af3b40aa6101942801292d Mon Sep 17 00:00:00 2001 From: Baptiste Mispelon Date: Fri, 26 Apr 2024 22:10:40 +0200 Subject: [PATCH] Fixed #12978 -- Added support for RSS feed stylesheets. --- django/contrib/syndication/views.py | 1 + django/utils/feedgenerator.py | 74 ++++++++++++++ docs/ref/contrib/syndication.txt | 123 +++++++++++++++++++++++- docs/ref/utils.txt | 46 ++++++++- docs/releases/5.2.txt | 5 +- tests/syndication_tests/feeds.py | 7 ++ tests/syndication_tests/tests.py | 123 ++++++++++++++++++++++++ tests/syndication_tests/urls.py | 6 ++ tests/utils_tests/test_feedgenerator.py | 11 +++ 9 files changed, 388 insertions(+), 8 deletions(-) diff --git a/django/contrib/syndication/views.py b/django/contrib/syndication/views.py index 2378a14874d2..0947ab212ce4 100644 --- a/django/contrib/syndication/views.py +++ b/django/contrib/syndication/views.py @@ -160,6 +160,7 @@ def get_feed(self, obj, request): feed_copyright=self._get_dynamic_attr("feed_copyright", obj), feed_guid=self._get_dynamic_attr("feed_guid", obj), ttl=self._get_dynamic_attr("ttl", obj), + stylesheets=self._get_dynamic_attr("stylesheets", obj), **self.feed_extra_kwargs(obj), ) diff --git a/django/utils/feedgenerator.py b/django/utils/feedgenerator.py index 3bd456ca687a..fae327143086 100644 --- a/django/utils/feedgenerator.py +++ b/django/utils/feedgenerator.py @@ -24,6 +24,7 @@ import datetime import email +import mimetypes from io import StringIO from urllib.parse import urlparse @@ -57,6 +58,53 @@ def get_tag_uri(url, date): return "tag:%s%s:%s/%s" % (bits.hostname, d, bits.path, bits.fragment) +def _guess_stylesheet_mimetype(url): + """ + Return the given stylesheet's mimetype tuple, using a slightly custom + version of Python's mimetypes.guess_type(). + """ + mimetypedb = mimetypes.MimeTypes() + + # The official mimetype for XSLT files is technically `application/xslt+xml` + # but as of 2024 almost no browser supports that (they all expect text/xsl). + # On top of that, windows seems to assume that the type for xsl is text/xml. + mimetypedb.readfp(StringIO("text/xsl\txsl\ntext/xsl\txslt")) + + return mimetypedb.guess_type(url) + + +class Stylesheet: + """An RSS stylesheet""" + + def __init__(self, url, mimetype="", media="screen"): + self._url = url + self._mimetype = mimetype + self.media = media + + # Using a property to delay the evaluation of self._url as late as possible + # in case of a lazy object (like reverse_lazy(...) for example). + @property + def url(self): + return iri_to_uri(self._url) + + @property + def mimetype(self): + if self._mimetype == "": + return _guess_stylesheet_mimetype(self.url)[0] + return self._mimetype + + def __str__(self): + data = [f'href="{self.url}"'] + if self.mimetype is not None: + data.append(f'type="{self.mimetype}"') + if self.media is not None: + data.append(f'media="{self.media}"') + return " ".join(data) + + def __repr__(self): + return repr((self.url, self.mimetype, self.media)) + + class SyndicationFeed: "Base class for all syndication feeds. Subclasses should provide write()" @@ -75,12 +123,24 @@ def __init__( feed_copyright=None, feed_guid=None, ttl=None, + stylesheets=None, **kwargs, ): def to_str(s): return str(s) if s is not None else s + def to_stylesheet(s): + return s if isinstance(s, Stylesheet) else Stylesheet(s) + categories = categories and [str(c) for c in categories] + + if stylesheets is not None: + if isinstance(stylesheets, (Stylesheet, str)): + raise TypeError( + f"stylesheets should be a list, not {stylesheets.__class__}" + ) + stylesheets = [to_stylesheet(s) for s in stylesheets] + self.feed = { "title": to_str(title), "link": iri_to_uri(link), @@ -95,6 +155,7 @@ def to_str(s): "feed_copyright": to_str(feed_copyright), "id": feed_guid or link, "ttl": to_str(ttl), + "stylesheets": stylesheets, **kwargs, } self.items = [] @@ -166,6 +227,12 @@ def add_root_elements(self, handler): """ pass + def add_stylesheets(self, handler): + """ + Add stylesheet(s) to the feed. Called from write(). + """ + pass + def item_attributes(self, item): """ Return extra attributes to place on each item (i.e. item/entry) element. @@ -228,6 +295,9 @@ class RssFeed(SyndicationFeed): def write(self, outfile, encoding): handler = SimplerXMLGenerator(outfile, encoding, short_empty_elements=True) handler.startDocument() + # Any stylesheet must come after the start of the document but before any tag. + # https://www.w3.org/Style/styling-XML.en.html + self.add_stylesheets(handler) handler.startElement("rss", self.rss_attributes()) handler.startElement("channel", self.root_attributes()) self.add_root_elements(handler) @@ -247,6 +317,10 @@ def write_items(self, handler): self.add_item_elements(handler, item) handler.endElement("item") + def add_stylesheets(self, handler): + for stylesheet in self.feed["stylesheets"] or []: + handler.processingInstruction("xml-stylesheet", stylesheet) + def add_root_elements(self, handler): handler.addQuickElement("title", self.feed["title"]) handler.addQuickElement("link", self.feed["link"]) diff --git a/docs/ref/contrib/syndication.txt b/docs/ref/contrib/syndication.txt index d9672c5b0006..d0a3cc41f7fc 100644 --- a/docs/ref/contrib/syndication.txt +++ b/docs/ref/contrib/syndication.txt @@ -596,6 +596,24 @@ This example illustrates all possible attributes and methods for a ttl = 600 # Hard-coded Time To Live. + # STYLESHEETS -- Optional. To set, provide one of the following three. + # The framework looks for them in this order. + + def stylesheets(self, obj): + """ + Takes the object returned by get_object() and returns the feed's + stylesheets (as URL strings or as Stylesheet instances). + """ + + def stylesheets(self): + """ + Returns the feed's stylesheets (as URL strings or Stylesheet + instances). + """ + + # Hardcoded stylesheets. + stylesheets = ["/stylesheet1.xsl", "stylesheet2.xsl"] + # ITEMS -- One of the following three is required. The framework looks # for them in this order. @@ -961,16 +979,26 @@ They share this interface: * ``feed_copyright`` * ``feed_guid`` * ``ttl`` + * ``stylesheets`` Any extra keyword arguments you pass to ``__init__`` will be stored in ``self.feed`` for use with `custom feed generators`_. - All parameters should be strings, except ``categories``, which should be a - sequence of strings. Beware that some control characters - are `not allowed `_ - in XML documents. If your content has some of them, you might encounter a + All parameters should be strings, except for two: + + * ``categories`` should be a sequence of strings. + * ``stylesheets`` should be a sequence of either strings or + :class:`~django.utils.feedgenerator.Stylesheet` instances. + + Beware that some control characters are + `not allowed `_ in + XML documents. If your content has some of them, you might encounter a :exc:`ValueError` when producing the feed. + .. versionchanged:: 5.2 + + The ``stylesheets`` argument was added. + :meth:`.SyndicationFeed.add_item` Add an item to the feed with the given parameters. @@ -1095,3 +1123,90 @@ For example, you might start implementing an iTunes RSS feed generator like so:: There's a lot more work to be done for a complete custom feed class, but the above example should demonstrate the basic idea. + +.. _feed-stylesheets: + +Feed stylesheets +---------------- + +.. versionadded:: 5.2 + +If you wish to have your RSS feed render nicely in a browser, you will need to +provide styling information for the XML file, typically in XSLT_ or CSS +formats. + +You can add this to your RSS feed by setting the ``stylesheets`` attribute on +the feed class. + +This can be a hardcoded URL:: + + from django.contrib.syndication.views import Feed + + + class FeedWithHardcodedStylesheet(Feed): + stylesheets = [ + "https://example.com/rss_stylesheet.xslt", + ] + +You can also use Django's static files system:: + + from django.contrib.syndication.views import Feed + from django.templatetags.static import static + + + class FeedWithStaticFileStylesheet(Feed): + stylesheets = [ + static("rss_styles.xslt"), + ] + +Another option is to have a view in your project that renders the XSLT +document. You can then link it like so:: + + from django.contrib.syndication.views import Feed + from django.urls import reverse_lazy + + + class FeedWithStylesheetView(Feed): + stylesheets = [ + reverse_lazy("your-custom-view-name"), + ] + +Django will normally try to guess the MIME type of the given URL based on its +extension, but if that fails you can specify it using the +:class:`~django.utils.feedgenerator.Stylesheet` class:: + + from django.contrib.syndication.views import Feed + from django.utils.feedgenerator import Stylesheet + + + class FeedWithHardcodedStylesheet(Feed): + stylesheets = [ + Stylesheet("https://example.com/rss_stylesheet", mimetype="text/xsl"), + ] + +Similarly, if you'd like to use a different ``media`` attribute than ``screen`` +(Django's default), you can use the +:class:`~django.utils.feedgenerator.Stylesheet` class again:: + + from django.contrib.syndication.views import Feed + from django.utils.feedgenerator import Stylesheet + + + class FeedWithHardcodedStylesheet(Feed): + stylesheets = [ + Stylesheet("https://example.com/rss_stylesheet.xslt", media="print"), + ] + +Any of these options can be combined when using multiple stylesheets:: + + from django.contrib.syndication.views import Feed + from django.utils.feedgenerator import Stylesheet + + + class MultiStylesheetFeed(Feed): + stylesheets = [ + "/stylesheet1.xsl", + Stylesheet("/stylesheet2.xsl"), + ] + +.. _xslt: https://developer.mozilla.org/en-US/docs/Web/XSLT/Transforming_XML_with_XSLT diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 3e357cba1717..9fb1e83e9e20 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -331,6 +331,32 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004 See https://web.archive.org/web/20110514113830/http://diveintomark.org/archives/2004/05/28/howto-atom-id +``Stylesheet`` +-------------- + +.. versionadded:: 5.2 + +.. class:: Stylesheet(url, mimetype="", media="screen") + + Represents an RSS stylesheet. + + .. attribute:: url + + Required argument. The URL where the stylesheet is located. + + .. attribute:: mimetype + + An optional string containing the MIME type of the stylesheet. If not + specified, Django will attempt to guess it by using Python's + :py:func:`mimetypes.guess_type()`. Use ``mimetype=None`` if you don't + want your stylesheet to have a MIME type specified. + + .. attribute:: media + + An optional string which will be used as the ``media`` attribute of + the stylesheet. Defaults to ``"screen"``. Use ``media=None`` if you + don't want your stylesheet to have a ``media`` attribute. + ``SyndicationFeed`` ------------------- @@ -339,7 +365,7 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004 Base class for all syndication feeds. Subclasses should provide ``write()``. - .. method:: __init__(title, link, description, language=None, author_email=None, author_name=None, author_link=None, subtitle=None, categories=None, feed_url=None, feed_copyright=None, feed_guid=None, ttl=None, **kwargs) + .. method:: __init__(title, link, description, language=None, author_email=None, author_name=None, author_link=None, subtitle=None, categories=None, feed_url=None, feed_copyright=None, feed_guid=None, ttl=None, stylesheets=None, **kwargs) Initialize the feed with the given dictionary of metadata, which applies to the entire feed. @@ -347,8 +373,15 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004 Any extra keyword arguments you pass to ``__init__`` will be stored in ``self.feed``. - All parameters should be strings, except ``categories``, which should - be a sequence of strings. + All parameters should be strings, except for two: + + * ``categories`` should be a sequence of strings. + * ``stylesheets`` should be a sequence of either strings or + :class:`Stylesheet` instances. + + .. versionchanged:: 5.2 + + The ``stylesheets`` argument was added. .. method:: add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, categories=(), item_copyright=None, ttl=None, updateddate=None, enclosures=None, **kwargs) @@ -368,6 +401,13 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004 Add elements in the root (i.e. feed/channel) element. Called from ``write()``. + .. method:: add_stylesheets(self, handler) + + .. versionadded:: 5.2 + + Add stylesheet information to the document. + Called from ``write()``. + .. method:: item_attributes(item) Return extra attributes to place on each item (i.e. item/entry) diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index 0a0cfb0aef3e..e0f190076a9f 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -100,7 +100,10 @@ Minor features :mod:`django.contrib.syndication` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* All :class:`~django.utils.feedgenerator.SyndicationFeed` classes now support + a ``stylesheets`` attribute. If specified, an ```` + processing instruction will be added to the top of the document for each + stylesheet in the given list. See :ref:`feed-stylesheets` for more details. Asynchronous views ~~~~~~~~~~~~~~~~~~ diff --git a/tests/syndication_tests/feeds.py b/tests/syndication_tests/feeds.py index a35dc29e2090..56e540c63385 100644 --- a/tests/syndication_tests/feeds.py +++ b/tests/syndication_tests/feeds.py @@ -236,6 +236,13 @@ def item_title(self, item): return "Title: %s" % item.title +class TestFeedWithStylesheets(TestRss2Feed): + stylesheets = [ + "/stylesheet1.xsl", + feedgenerator.Stylesheet("/stylesheet2.xsl"), + ] + + class NaiveDatesFeed(TestAtomFeed): """ A feed with naive (non-timezone-aware) dates. diff --git a/tests/syndication_tests/tests.py b/tests/syndication_tests/tests.py index a68ed879db85..6403f7461a56 100644 --- a/tests/syndication_tests/tests.py +++ b/tests/syndication_tests/tests.py @@ -4,12 +4,16 @@ from django.contrib.sites.models import Site from django.contrib.syndication import views from django.core.exceptions import ImproperlyConfigured +from django.templatetags.static import static from django.test import TestCase, override_settings from django.test.utils import requires_tz_support +from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.feedgenerator import ( Atom1Feed, Rss201rev2Feed, + Stylesheet, + SyndicationFeed, rfc2822_date, rfc3339_date, ) @@ -561,6 +565,125 @@ def test_feed_no_content_self_closing_tag(self): doc = feed.writeString("utf-8") self.assertIn(f'<{tag} href="https://feed.url.com" rel="self"/>', doc) + def test_stylesheets_none(self): + feed = Rss201rev2Feed( + title="test", + link="https://example.com", + description="test", + stylesheets=None, + ) + self.assertNotIn("xml-stylesheet", feed.writeString("utf-8")) + + def test_stylesheets(self): + testdata = [ + # Plain strings. + ("/test.xsl", 'href="/test.xsl" type="text/xsl" media="screen"'), + ("/test.xslt", 'href="/test.xslt" type="text/xsl" media="screen"'), + ("/test.css", 'href="/test.css" type="text/css" media="screen"'), + ("/test", 'href="/test" media="screen"'), + ( + "https://example.com/test.xsl", + 'href="https://example.com/test.xsl" type="text/xsl" media="screen"', + ), + ( + "https://example.com/test.css", + 'href="https://example.com/test.css" type="text/css" media="screen"', + ), + ( + "https://example.com/test", + 'href="https://example.com/test" media="screen"', + ), + ("/♥.xsl", 'href="/%E2%99%A5.xsl" type="text/xsl" media="screen"'), + ( + static("stylesheet.xsl"), + 'href="/static/stylesheet.xsl" type="text/xsl" media="screen"', + ), + ( + static("stylesheet.css"), + 'href="/static/stylesheet.css" type="text/css" media="screen"', + ), + (static("stylesheet"), 'href="/static/stylesheet" media="screen"'), + ( + reverse("syndication-xsl-stylesheet"), + 'href="/syndication/stylesheet.xsl" type="text/xsl" media="screen"', + ), + ( + reverse_lazy("syndication-xsl-stylesheet"), + 'href="/syndication/stylesheet.xsl" type="text/xsl" media="screen"', + ), + # Stylesheet objects. + ( + Stylesheet("/test.xsl"), + 'href="/test.xsl" type="text/xsl" media="screen"', + ), + (Stylesheet("/test.xsl", mimetype=None), 'href="/test.xsl" media="screen"'), + (Stylesheet("/test.xsl", media=None), 'href="/test.xsl" type="text/xsl"'), + (Stylesheet("/test.xsl", mimetype=None, media=None), 'href="/test.xsl"'), + ( + Stylesheet("/test.xsl", mimetype="text/xml"), + 'href="/test.xsl" type="text/xml" media="screen"', + ), + ] + for stylesheet, expected in testdata: + feed = Rss201rev2Feed( + title="test", + link="https://example.com", + description="test", + stylesheets=[stylesheet], + ) + doc = feed.writeString("utf-8") + with self.subTest(expected=expected): + self.assertIn(f"", doc) + + def test_stylesheets_instructions_are_at_the_top(self): + response = self.client.get("/syndication/stylesheet/") + doc = minidom.parseString(response.content) + self.assertEqual(doc.childNodes[0].nodeName, "xml-stylesheet") + self.assertEqual( + doc.childNodes[0].data, + 'href="/stylesheet1.xsl" type="text/xsl" media="screen"', + ) + self.assertEqual(doc.childNodes[1].nodeName, "xml-stylesheet") + self.assertEqual( + doc.childNodes[1].data, + 'href="/stylesheet2.xsl" type="text/xsl" media="screen"', + ) + + def test_stylesheets_typeerror_if_str_or_stylesheet(self): + for stylesheet, error_message in [ + ("/stylesheet.xsl", "stylesheets should be a list, not "), + ( + Stylesheet("/stylesheet.xsl"), + "stylesheets should be a list, " + "not ", + ), + ]: + args = ("title", "/link", "description") + with self.subTest(stylesheets=stylesheet): + self.assertRaisesMessage( + TypeError, + error_message, + SyndicationFeed, + *args, + stylesheets=stylesheet, + ) + + def test_stylesheets_repr(self): + testdata = [ + (Stylesheet("/test.xsl", mimetype=None), "('/test.xsl', None, 'screen')"), + (Stylesheet("/test.xsl", media=None), "('/test.xsl', 'text/xsl', None)"), + ( + Stylesheet("/test.xsl", mimetype=None, media=None), + "('/test.xsl', None, None)", + ), + ( + Stylesheet("/test.xsl", mimetype="text/xml"), + "('/test.xsl', 'text/xml', 'screen')", + ), + ] + for stylesheet, expected in testdata: + self.assertEqual(repr(stylesheet), expected) + @requires_tz_support def test_feed_last_modified_time_naive_date(self): """ diff --git a/tests/syndication_tests/urls.py b/tests/syndication_tests/urls.py index 50f673373ec4..bb1d3d990df2 100644 --- a/tests/syndication_tests/urls.py +++ b/tests/syndication_tests/urls.py @@ -36,8 +36,14 @@ path("syndication/articles/", feeds.ArticlesFeed()), path("syndication/template/", feeds.TemplateFeed()), path("syndication/template_context/", feeds.TemplateContextFeed()), + path("syndication/stylesheet/", feeds.TestFeedWithStylesheets()), path("syndication/rss2/single-enclosure/", feeds.TestSingleEnclosureRSSFeed()), path("syndication/rss2/multiple-enclosure/", feeds.TestMultipleEnclosureRSSFeed()), path("syndication/atom/single-enclosure/", feeds.TestSingleEnclosureAtomFeed()), path("syndication/atom/multiple-enclosure/", feeds.TestMultipleEnclosureAtomFeed()), + path( + "syndication/stylesheet.xsl", + lambda request: None, + name="syndication-xsl-stylesheet", + ), ] diff --git a/tests/utils_tests/test_feedgenerator.py b/tests/utils_tests/test_feedgenerator.py index ee15b6e92883..e5ceafb8fa2e 100644 --- a/tests/utils_tests/test_feedgenerator.py +++ b/tests/utils_tests/test_feedgenerator.py @@ -1,7 +1,9 @@ import datetime +from unittest import mock from django.test import SimpleTestCase from django.utils import feedgenerator +from django.utils.functional import SimpleLazyObject from django.utils.timezone import get_fixed_timezone @@ -148,3 +150,12 @@ def test_latest_post_date_returns_utc_time(self): rss_feed.latest_post_date().tzinfo, datetime.timezone.utc, ) + + def test_stylesheet_keeps_lazy_urls(self): + m = mock.Mock(return_value="test.css") + stylesheet = feedgenerator.Stylesheet(SimpleLazyObject(m)) + m.assert_not_called() + self.assertEqual( + str(stylesheet), 'href="test.css" type="text/css" media="screen"' + ) + m.assert_called_once()