diff --git a/django/contrib/syndication/views.py b/django/contrib/syndication/views.py index 7bcc9516115..2e5e5f3ef9c 100644 --- a/django/contrib/syndication/views.py +++ b/django/contrib/syndication/views.py @@ -64,6 +64,17 @@ class Feed(object): 'item_link() method in your Feed class.' % item.__class__.__name__ ) + def item_enclosures(self, item): + enc_url = self.__get_dynamic_attr('item_enclosure_url', item) + if enc_url: + enc = feedgenerator.Enclosure( + url=smart_text(enc_url), + length=smart_text(self.__get_dynamic_attr('item_enclosure_length', item)), + mime_type=smart_text(self.__get_dynamic_attr('item_enclosure_mime_type', item)), + ) + return [enc] + return [] + def __get_dynamic_attr(self, attname, obj, default=None): try: attr = getattr(self, attname) @@ -171,14 +182,7 @@ class Feed(object): self.__get_dynamic_attr('item_link', item), request.is_secure(), ) - enc = None - enc_url = self.__get_dynamic_attr('item_enclosure_url', item) - if enc_url: - enc = feedgenerator.Enclosure( - url=smart_text(enc_url), - length=smart_text(self.__get_dynamic_attr('item_enclosure_length', item)), - mime_type=smart_text(self.__get_dynamic_attr('item_enclosure_mime_type', item)) - ) + enclosures = self.__get_dynamic_attr('item_enclosures', item) author_name = self.__get_dynamic_attr('item_author_name', item) if author_name is not None: author_email = self.__get_dynamic_attr('item_author_email', item) @@ -203,7 +207,7 @@ class Feed(object): unique_id=self.__get_dynamic_attr('item_guid', item, link), unique_id_is_permalink=self.__get_dynamic_attr( 'item_guid_is_permalink', item), - enclosure=enc, + enclosures=enclosures, pubdate=pubdate, updateddate=updateddate, author_name=author_name, diff --git a/django/utils/feedgenerator.py b/django/utils/feedgenerator.py index 9b1421160b8..c67fedb277d 100644 --- a/django/utils/feedgenerator.py +++ b/django/utils/feedgenerator.py @@ -118,11 +118,13 @@ class SyndicationFeed(object): def add_item(self, title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, unique_id_is_permalink=None, enclosure=None, - categories=(), item_copyright=None, ttl=None, updateddate=None, **kwargs): + categories=(), item_copyright=None, ttl=None, updateddate=None, + enclosures=None, **kwargs): """ Adds an item to the feed. All args are expected to be Python Unicode objects except pubdate and updateddate, which are datetime.datetime - objects, and enclosure, which is an instance of the Enclosure class. + objects, and enclosures, which is an iterable of instances of the + Enclosure class. """ to_unicode = lambda s: force_text(s, strings_only=True) if categories: @@ -130,6 +132,16 @@ class SyndicationFeed(object): if ttl is not None: # Force ints to unicode ttl = force_text(ttl) + if enclosure is None: + enclosures = [] if enclosures is None else enclosures + else: + warnings.warn( + "The enclosure keyword argument is deprecated, " + "use enclosures instead.", + RemovedInDjango20Warning, + stacklevel=2, + ) + enclosures = [enclosure] item = { 'title': to_unicode(title), 'link': iri_to_uri(link), @@ -142,7 +154,7 @@ class SyndicationFeed(object): 'comments': to_unicode(comments), 'unique_id': to_unicode(unique_id), 'unique_id_is_permalink': unique_id_is_permalink, - 'enclosure': enclosure, + 'enclosures': enclosures, 'categories': categories or (), 'item_copyright': to_unicode(item_copyright), 'ttl': ttl, @@ -317,10 +329,19 @@ class Rss201rev2Feed(RssFeed): handler.addQuickElement("ttl", item['ttl']) # Enclosure. - if item['enclosure'] is not None: - handler.addQuickElement("enclosure", '', - {"url": item['enclosure'].url, "length": item['enclosure'].length, - "type": item['enclosure'].mime_type}) + if item['enclosures']: + enclosures = list(item['enclosures']) + if len(enclosures) > 1: + raise ValueError( + "RSS feed items may only have one enclosure, see " + "http://www.rssboard.org/rss-profile#element-channel-item-enclosure" + ) + enclosure = enclosures[0] + handler.addQuickElement('enclosure', '', { + 'url': enclosure.url, + 'length': enclosure.length, + 'type': enclosure.mime_type, + }) # Categories. for cat in item['categories']: @@ -328,7 +349,7 @@ class Rss201rev2Feed(RssFeed): class Atom1Feed(SyndicationFeed): - # Spec: http://atompub.org/2005/07/11/draft-ietf-atompub-format-10.html + # Spec: https://tools.ietf.org/html/rfc4287 content_type = 'application/atom+xml; charset=utf-8' ns = "http://www.w3.org/2005/Atom" @@ -405,13 +426,14 @@ class Atom1Feed(SyndicationFeed): if item['description'] is not None: handler.addQuickElement("summary", item['description'], {"type": "html"}) - # Enclosure. - if item['enclosure'] is not None: - handler.addQuickElement("link", '', - {"rel": "enclosure", - "href": item['enclosure'].url, - "length": item['enclosure'].length, - "type": item['enclosure'].mime_type}) + # Enclosures. + for enclosure in item.get('enclosures') or []: + handler.addQuickElement('link', '', { + 'rel': 'enclosure', + 'href': enclosure.url, + 'length': enclosure.length, + 'type': enclosure.mime_type, + }) # Categories. for cat in item['categories']: diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index f37ec251791..fb3dc859c32 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -94,6 +94,9 @@ details on these changes. * The ``callable_obj`` keyword argument to ``SimpleTestCase.assertRaisesMessage()`` will be removed. +* The ``enclosure`` keyword argument to ``SyndicationFeed.add_item()`` will be + removed. + .. _deprecation-removed-in-1.10: 1.10 diff --git a/docs/ref/contrib/syndication.txt b/docs/ref/contrib/syndication.txt index 4d0e3da100f..0a91a16e40a 100644 --- a/docs/ref/contrib/syndication.txt +++ b/docs/ref/contrib/syndication.txt @@ -298,10 +298,16 @@ Enclosures ---------- To specify enclosures, such as those used in creating podcast feeds, use the -``item_enclosure_url``, ``item_enclosure_length`` and +``item_enclosures`` hook or, alternatively and if you only have a single +enclosure per item, the ``item_enclosure_url``, ``item_enclosure_length``, and ``item_enclosure_mime_type`` hooks. See the ``ExampleFeed`` class below for usage examples. +.. versionchanged:: 1.9 + + Support for multiple enclosures per feed item was added through the + ``item_enclosures`` hook. + Language -------- @@ -742,8 +748,28 @@ This example illustrates all possible attributes and methods for a item_author_link = 'http://www.example.com/' # Hard-coded author URL. + # ITEM ENCLOSURES -- One of the following three is optional. The + # framework looks for them in this order. If one of them is defined, + # ``item_enclosure_url``, ``item_enclosure_length``, and + # ``item_enclosure_mime_type`` will have no effect. + + def item_enclosures(self, item): + """ + Takes an item, as returned by items(), and returns a list of + ``django.utils.feedgenerator.Enclosure`` objects. + """ + + def item_enclosure_url(self): + """ + Returns the ``django.utils.feedgenerator.Enclosure`` list for every + item in the feed. + """ + + item_enclosures = [] # Hard-coded enclosure list + # ITEM ENCLOSURE URL -- One of these three is required if you're - # publishing enclosures. The framework looks for them in this order. + # publishing enclosures and you're not using ``item_enclosures``. The + # framework looks for them in this order. def item_enclosure_url(self, item): """ @@ -759,9 +785,10 @@ This example illustrates all possible attributes and methods for a item_enclosure_url = "/foo/bar.mp3" # Hard-coded enclosure link. # ITEM ENCLOSURE LENGTH -- One of these three is required if you're - # publishing enclosures. The framework looks for them in this order. - # In each case, the returned value should be either an integer, or a - # string representation of the integer, in bytes. + # publishing enclosures and you're not using ``item_enclosures``. The + # framework looks for them in this order. In each case, the returned + # value should be either an integer, or a string representation of the + # integer, in bytes. def item_enclosure_length(self, item): """ @@ -777,7 +804,8 @@ This example illustrates all possible attributes and methods for a item_enclosure_length = 32000 # Hard-coded enclosure length. # ITEM ENCLOSURE MIME TYPE -- One of these three is required if you're - # publishing enclosures. The framework looks for them in this order. + # publishing enclosures and you're not using ``item_enclosures``. The + # framework looks for them in this order. def item_enclosure_mime_type(self, item): """ @@ -941,6 +969,7 @@ They share this interface: * ``comments`` * ``unique_id`` * ``enclosure`` + * ``enclosures`` * ``categories`` * ``item_copyright`` * ``ttl`` @@ -954,8 +983,15 @@ They share this interface: * ``updateddate`` should be a Python :class:`~datetime.datetime` object. * ``enclosure`` should be an instance of :class:`django.utils.feedgenerator.Enclosure`. + * ``enclosures`` should be a list of + :class:`django.utils.feedgenerator.Enclosure` instances. * ``categories`` should be a sequence of Unicode objects. + .. deprecated:: 1.9 + + The ``enclosure`` keyword argument is deprecated in favor of the + ``enclosures`` keyword argument. + :meth:`.SyndicationFeed.write` Outputs the feed in the given encoding to outfile, which is a file-like object. diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index c416c82db1f..c1df4588724 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -351,11 +351,18 @@ SyndicationFeed All parameters should be Unicode objects, except ``categories``, which should be a sequence of Unicode objects. - .. method:: add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, enclosure=None, categories=(), item_copyright=None, ttl=None, updateddate=None, **kwargs) + .. method:: add_item(title, link, description, author_email=None, author_name=None, author_link=None, pubdate=None, comments=None, unique_id=None, enclosure=None, categories=(), item_copyright=None, ttl=None, updateddate=None, enclosures=None, **kwargs) Adds an item to the feed. All args are expected to be Python ``unicode`` objects except ``pubdate`` and ``updateddate``, which are ``datetime.datetime`` - objects, and ``enclosure``, which is an instance of the ``Enclosure`` class. + objects, ``enclosure``, which is an ``Enclosure`` instance, and + ``enclosures``, which is a list of ``Enclosure`` instances. + + .. deprecated:: 1.9 + + The ``enclosure`` keyword argument is deprecated in favor of the + new ``enclosures`` keyword argument which accepts a list of + ``Enclosure`` objects. .. method:: num_items() diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index 30f812c4d97..411d8bc2550 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -303,7 +303,9 @@ Minor features :mod:`django.contrib.syndication` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* ... +* Support for multiple enclosures per feed item has been added. If multiple + enclosures are defined on a RSS feed, an exception is raised as RSS feeds, + unlike Atom feeds, do not support multiple enclosures per feed item. Cache ^^^^^ @@ -1265,6 +1267,10 @@ Miscellaneous :func:`~django.utils.safestring.mark_safe` when constructing the method's return value instead. +* The ``enclosure`` keyword argument to ``SyndicationFeed.add_item()`` is + deprecated. Use the new ``enclosures`` argument which accepts a list of + ``Enclosure`` objects instead of a single one. + .. removed-features-1.9: Features removed in 1.9 diff --git a/tests/syndication_tests/feeds.py b/tests/syndication_tests/feeds.py index 6408838b681..ce938b8356a 100644 --- a/tests/syndication_tests/feeds.py +++ b/tests/syndication_tests/feeds.py @@ -88,8 +88,29 @@ class ArticlesFeed(TestRss2Feed): return Article.objects.all() -class TestEnclosureFeed(TestRss2Feed): - pass +class TestSingleEnclosureRSSFeed(TestRss2Feed): + """ + A feed to test that RSS feeds work with a single enclosure. + """ + def item_enclosure_url(self, item): + return 'http://example.com' + + def item_enclosure_size(self, item): + return 0 + + def item_mime_type(self, item): + return 'image/png' + + +class TestMultipleEnclosureRSSFeed(TestRss2Feed): + """ + A feed to test that RSS feeds raise an exception with multiple enclosures. + """ + def item_enclosures(self, item): + return [ + feedgenerator.Enclosure('http://example.com/hello.png', 0, 'image/png'), + feedgenerator.Enclosure('http://example.com/goodbye.png', 0, 'image/png'), + ] class TemplateFeed(TestRss2Feed): @@ -165,3 +186,28 @@ class MyCustomAtom1Feed(feedgenerator.Atom1Feed): class TestCustomFeed(TestAtomFeed): feed_type = MyCustomAtom1Feed + + +class TestSingleEnclosureAtomFeed(TestAtomFeed): + """ + A feed to test that Atom feeds work with a single enclosure. + """ + def item_enclosure_url(self, item): + return 'http://example.com' + + def item_enclosure_size(self, item): + return 0 + + def item_mime_type(self, item): + return 'image/png' + + +class TestMultipleEnclosureAtomFeed(TestAtomFeed): + """ + A feed to test that Atom feeds work with multiple enclosures. + """ + def item_enclosures(self, item): + return [ + feedgenerator.Enclosure('http://example.com/hello.png', 0, 'image/png'), + feedgenerator.Enclosure('http://example.com/goodbye.png', 0, 'image/png'), + ] diff --git a/tests/syndication_tests/tests.py b/tests/syndication_tests/tests.py index 78c8708f9e7..5a7cfbb0572 100644 --- a/tests/syndication_tests/tests.py +++ b/tests/syndication_tests/tests.py @@ -9,7 +9,10 @@ from django.core.exceptions import ImproperlyConfigured from django.test import TestCase, override_settings from django.test.utils import requires_tz_support from django.utils import timezone -from django.utils.feedgenerator import rfc2822_date, rfc3339_date +from django.utils.deprecation import RemovedInDjango20Warning +from django.utils.feedgenerator import ( + Enclosure, SyndicationFeed, rfc2822_date, rfc3339_date, +) from .models import Article, Entry @@ -63,10 +66,6 @@ class FeedTestCase(TestCase): set(expected) ) -###################################### -# Feed view -###################################### - @override_settings(ROOT_URLCONF='syndication_tests.urls') class SyndicationFeedTest(FeedTestCase): @@ -186,6 +185,22 @@ class SyndicationFeedTest(FeedTestCase): item.getElementsByTagName('guid')[0].attributes.get( 'isPermaLink').value, "true") + def test_rss2_single_enclosure(self): + response = self.client.get('/syndication/rss2/single-enclosure/') + doc = minidom.parseString(response.content) + chan = doc.getElementsByTagName('rss')[0].getElementsByTagName('channel')[0] + items = chan.getElementsByTagName('item') + for item in items: + enclosures = item.getElementsByTagName('enclosure') + self.assertEqual(len(enclosures), 1) + + def test_rss2_multiple_enclosures(self): + with self.assertRaisesMessage(ValueError, ( + "RSS feed items may only have one enclosure, see " + "http://www.rssboard.org/rss-profile#element-channel-item-enclosure" + )): + self.client.get('/syndication/rss2/multiple-enclosure/') + def test_rss091_feed(self): """ Test the structure and content of feeds generated by RssUserland091Feed. @@ -284,6 +299,24 @@ class SyndicationFeedTest(FeedTestCase): self.assertNotEqual(published, updated) + def test_atom_single_enclosure(self): + response = self.client.get('/syndication/rss2/single-enclosure/') + feed = minidom.parseString(response.content).firstChild + items = feed.getElementsByTagName('entry') + for item in items: + links = item.getElementsByTagName('link') + links = [link for link in links if link.getAttribute('rel') == 'enclosure'] + self.assertEqual(len(links), 1) + + def test_atom_multiple_enclosures(self): + response = self.client.get('/syndication/rss2/single-enclosure/') + feed = minidom.parseString(response.content).firstChild + items = feed.getElementsByTagName('entry') + for item in items: + links = item.getElementsByTagName('link') + links = [link for link in links if link.getAttribute('rel') == 'enclosure'] + self.assertEqual(len(links), 2) + def test_latest_post_date(self): """ Test that both the published and updated dates are @@ -493,3 +526,17 @@ class SyndicationFeedTest(FeedTestCase): views.add_domain('example.com', '//example.com/foo/?arg=value'), 'http://example.com/foo/?arg=value' ) + + +class FeedgeneratorTestCase(TestCase): + def test_add_item_warns_when_enclosure_kwarg_is_used(self): + feed = SyndicationFeed(title='Example', link='http://example.com', description='Foo') + with self.assertRaisesMessage(RemovedInDjango20Warning, ( + 'The enclosure keyword argument is deprecated, use enclosures instead.' + )): + feed.add_item( + title='Example Item', + link='https://example.com/item', + description='bar', + enclosure=Enclosure('http://example.com/favicon.ico', 0, 'image/png'), + ) diff --git a/tests/syndication_tests/urls.py b/tests/syndication_tests/urls.py index d3caeae8881..09f7e789cdd 100644 --- a/tests/syndication_tests/urls.py +++ b/tests/syndication_tests/urls.py @@ -19,4 +19,8 @@ urlpatterns = [ url(r'^syndication/articles/$', feeds.ArticlesFeed()), url(r'^syndication/template/$', feeds.TemplateFeed()), url(r'^syndication/template_context/$', feeds.TemplateContextFeed()), + url(r'^syndication/rss2/single-enclosure/$', feeds.TestSingleEnclosureRSSFeed()), + url(r'^syndication/rss2/multiple-enclosure/$', feeds.TestMultipleEnclosureRSSFeed()), + url(r'^syndication/atom/single-enclosure/$', feeds.TestSingleEnclosureAtomFeed()), + url(r'^syndication/atom/multiple-enclosure/$', feeds.TestMultipleEnclosureAtomFeed()), ]