Fixed #13110 -- Added support for multiple enclosures in Atom feeds.

The ``item_enclosures`` hook returns a list of ``Enclosure`` objects which is
then used by the feed builder. If the feed is a RSS feed, an exception is
raised as RSS feeds don't allow multiple enclosures per feed item.

The ``item_enclosures`` hook defaults to an empty list or, if the
``item_enclosure_url`` hook is defined, to a list with a single ``Enclosure``
built from the ``item_enclosure_url``, ``item_enclosure_length``, and
``item_enclosure_mime_type`` hooks.
This commit is contained in:
Unai Zalakain 2015-08-21 11:50:43 +02:00 committed by Tim Graham
parent 71ebcb85b9
commit aac2a2d2ae
9 changed files with 215 additions and 40 deletions

View File

@ -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,

View File

@ -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']:

View File

@ -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

View File

@ -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.

View File

@ -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()

View File

@ -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

View File

@ -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'),
]

View File

@ -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'),
)

View File

@ -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()),
]