1
0
mirror of https://github.com/django/django.git synced 2024-12-22 09:05:43 +00:00

Fixed #12978 -- Added support for RSS feed stylesheets.

This commit is contained in:
Baptiste Mispelon 2024-04-26 22:10:40 +02:00 committed by Sarah Boyce
parent ce1ad98565
commit 62300b81cf
9 changed files with 388 additions and 8 deletions

View File

@ -160,6 +160,7 @@ class Feed:
feed_copyright=self._get_dynamic_attr("feed_copyright", obj), feed_copyright=self._get_dynamic_attr("feed_copyright", obj),
feed_guid=self._get_dynamic_attr("feed_guid", obj), feed_guid=self._get_dynamic_attr("feed_guid", obj),
ttl=self._get_dynamic_attr("ttl", obj), ttl=self._get_dynamic_attr("ttl", obj),
stylesheets=self._get_dynamic_attr("stylesheets", obj),
**self.feed_extra_kwargs(obj), **self.feed_extra_kwargs(obj),
) )

View File

@ -24,6 +24,7 @@ https://web.archive.org/web/20110718035220/http://diveintomark.org/archives/2004
import datetime import datetime
import email import email
import mimetypes
from io import StringIO from io import StringIO
from urllib.parse import urlparse 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) 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: class SyndicationFeed:
"Base class for all syndication feeds. Subclasses should provide write()" "Base class for all syndication feeds. Subclasses should provide write()"
@ -75,12 +123,24 @@ class SyndicationFeed:
feed_copyright=None, feed_copyright=None,
feed_guid=None, feed_guid=None,
ttl=None, ttl=None,
stylesheets=None,
**kwargs, **kwargs,
): ):
def to_str(s): def to_str(s):
return str(s) if s is not None else 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] 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 = { self.feed = {
"title": to_str(title), "title": to_str(title),
"link": iri_to_uri(link), "link": iri_to_uri(link),
@ -95,6 +155,7 @@ class SyndicationFeed:
"feed_copyright": to_str(feed_copyright), "feed_copyright": to_str(feed_copyright),
"id": feed_guid or link, "id": feed_guid or link,
"ttl": to_str(ttl), "ttl": to_str(ttl),
"stylesheets": stylesheets,
**kwargs, **kwargs,
} }
self.items = [] self.items = []
@ -166,6 +227,12 @@ class SyndicationFeed:
""" """
pass pass
def add_stylesheets(self, handler):
"""
Add stylesheet(s) to the feed. Called from write().
"""
pass
def item_attributes(self, item): def item_attributes(self, item):
""" """
Return extra attributes to place on each item (i.e. item/entry) element. 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): def write(self, outfile, encoding):
handler = SimplerXMLGenerator(outfile, encoding, short_empty_elements=True) handler = SimplerXMLGenerator(outfile, encoding, short_empty_elements=True)
handler.startDocument() 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("rss", self.rss_attributes())
handler.startElement("channel", self.root_attributes()) handler.startElement("channel", self.root_attributes())
self.add_root_elements(handler) self.add_root_elements(handler)
@ -247,6 +317,10 @@ class RssFeed(SyndicationFeed):
self.add_item_elements(handler, item) self.add_item_elements(handler, item)
handler.endElement("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): def add_root_elements(self, handler):
handler.addQuickElement("title", self.feed["title"]) handler.addQuickElement("title", self.feed["title"])
handler.addQuickElement("link", self.feed["link"]) handler.addQuickElement("link", self.feed["link"])

View File

@ -596,6 +596,24 @@ This example illustrates all possible attributes and methods for a
ttl = 600 # Hard-coded Time To Live. 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 # ITEMS -- One of the following three is required. The framework looks
# for them in this order. # for them in this order.
@ -961,16 +979,26 @@ They share this interface:
* ``feed_copyright`` * ``feed_copyright``
* ``feed_guid`` * ``feed_guid``
* ``ttl`` * ``ttl``
* ``stylesheets``
Any extra keyword arguments you pass to ``__init__`` will be stored in Any extra keyword arguments you pass to ``__init__`` will be stored in
``self.feed`` for use with `custom feed generators`_. ``self.feed`` for use with `custom feed generators`_.
All parameters should be strings, except ``categories``, which should be a All parameters should be strings, except for two:
sequence of strings. Beware that some control characters
are `not allowed <https://www.w3.org/International/questions/qa-controls>`_ * ``categories`` should be a sequence of strings.
in XML documents. If your content has some of them, you might encounter a * ``stylesheets`` should be a sequence of either strings or
:class:`~django.utils.feedgenerator.Stylesheet` instances.
Beware that some control characters are
`not allowed <https://www.w3.org/International/questions/qa-controls>`_ in
XML documents. If your content has some of them, you might encounter a
:exc:`ValueError` when producing the feed. :exc:`ValueError` when producing the feed.
.. versionchanged:: 5.2
The ``stylesheets`` argument was added.
:meth:`.SyndicationFeed.add_item` :meth:`.SyndicationFeed.add_item`
Add an item to the feed with the given parameters. 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 There's a lot more work to be done for a complete custom feed class, but the
above example should demonstrate the basic idea. 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

View File

@ -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 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`` ``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 Base class for all syndication feeds. Subclasses should provide
``write()``. ``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 Initialize the feed with the given dictionary of metadata, which applies
to the entire feed. 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 Any extra keyword arguments you pass to ``__init__`` will be stored in
``self.feed``. ``self.feed``.
All parameters should be strings, except ``categories``, which should All parameters should be strings, except for two:
be a sequence of strings.
* ``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) .. 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. Add elements in the root (i.e. feed/channel) element.
Called from ``write()``. Called from ``write()``.
.. method:: add_stylesheets(self, handler)
.. versionadded:: 5.2
Add stylesheet information to the document.
Called from ``write()``.
.. method:: item_attributes(item) .. method:: item_attributes(item)
Return extra attributes to place on each item (i.e. item/entry) Return extra attributes to place on each item (i.e. item/entry)

View File

@ -100,7 +100,10 @@ Minor features
:mod:`django.contrib.syndication` :mod:`django.contrib.syndication`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ... * All :class:`~django.utils.feedgenerator.SyndicationFeed` classes now support
a ``stylesheets`` attribute. If specified, an ``<? xml-stylesheet ?>``
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 Asynchronous views
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~

View File

@ -236,6 +236,13 @@ class TestGetObjectFeed(TestRss2Feed):
return "Title: %s" % item.title return "Title: %s" % item.title
class TestFeedWithStylesheets(TestRss2Feed):
stylesheets = [
"/stylesheet1.xsl",
feedgenerator.Stylesheet("/stylesheet2.xsl"),
]
class NaiveDatesFeed(TestAtomFeed): class NaiveDatesFeed(TestAtomFeed):
""" """
A feed with naive (non-timezone-aware) dates. A feed with naive (non-timezone-aware) dates.

View File

@ -4,12 +4,16 @@ from xml.dom import minidom
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.syndication import views from django.contrib.syndication import views
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.templatetags.static import static
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.utils import requires_tz_support from django.test.utils import requires_tz_support
from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.feedgenerator import ( from django.utils.feedgenerator import (
Atom1Feed, Atom1Feed,
Rss201rev2Feed, Rss201rev2Feed,
Stylesheet,
SyndicationFeed,
rfc2822_date, rfc2822_date,
rfc3339_date, rfc3339_date,
) )
@ -561,6 +565,125 @@ class SyndicationFeedTest(FeedTestCase):
doc = feed.writeString("utf-8") doc = feed.writeString("utf-8")
self.assertIn(f'<{tag} href="https://feed.url.com" rel="self"/>', doc) 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"<?xml-stylesheet {expected}?>", 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 <class 'str'>"),
(
Stylesheet("/stylesheet.xsl"),
"stylesheets should be a list, "
"not <class 'django.utils.feedgenerator.Stylesheet'>",
),
]:
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 @requires_tz_support
def test_feed_last_modified_time_naive_date(self): def test_feed_last_modified_time_naive_date(self):
""" """

View File

@ -36,8 +36,14 @@ urlpatterns = [
path("syndication/articles/", feeds.ArticlesFeed()), path("syndication/articles/", feeds.ArticlesFeed()),
path("syndication/template/", feeds.TemplateFeed()), path("syndication/template/", feeds.TemplateFeed()),
path("syndication/template_context/", feeds.TemplateContextFeed()), path("syndication/template_context/", feeds.TemplateContextFeed()),
path("syndication/stylesheet/", feeds.TestFeedWithStylesheets()),
path("syndication/rss2/single-enclosure/", feeds.TestSingleEnclosureRSSFeed()), path("syndication/rss2/single-enclosure/", feeds.TestSingleEnclosureRSSFeed()),
path("syndication/rss2/multiple-enclosure/", feeds.TestMultipleEnclosureRSSFeed()), path("syndication/rss2/multiple-enclosure/", feeds.TestMultipleEnclosureRSSFeed()),
path("syndication/atom/single-enclosure/", feeds.TestSingleEnclosureAtomFeed()), path("syndication/atom/single-enclosure/", feeds.TestSingleEnclosureAtomFeed()),
path("syndication/atom/multiple-enclosure/", feeds.TestMultipleEnclosureAtomFeed()), path("syndication/atom/multiple-enclosure/", feeds.TestMultipleEnclosureAtomFeed()),
path(
"syndication/stylesheet.xsl",
lambda request: None,
name="syndication-xsl-stylesheet",
),
] ]

View File

@ -1,7 +1,9 @@
import datetime import datetime
from unittest import mock
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.utils import feedgenerator from django.utils import feedgenerator
from django.utils.functional import SimpleLazyObject
from django.utils.timezone import get_fixed_timezone from django.utils.timezone import get_fixed_timezone
@ -148,3 +150,12 @@ class FeedgeneratorTests(SimpleTestCase):
rss_feed.latest_post_date().tzinfo, rss_feed.latest_post_date().tzinfo,
datetime.timezone.utc, 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()