Skip to content

Commit

Permalink
Fixed #13110 -- Added support for multiple enclosures in Atom feeds.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Unai Zalakain authored and timgraham committed Sep 18, 2015
1 parent 71ebcb8 commit aac2a2d
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 40 deletions.
22 changes: 13 additions & 9 deletions django/contrib/syndication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ def item_link(self, item):
'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)
Expand Down Expand Up @@ -171,14 +182,7 @@ def get_feed(self, obj, request):
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)
Expand All @@ -203,7 +207,7 @@ def get_feed(self, obj, request):
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,
Expand Down
52 changes: 37 additions & 15 deletions django/utils/feedgenerator.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,30 @@ def __init__(self, title, link, description, language=None, author_email=None,
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:
categories = [to_unicode(c) for c in categories]
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),
Expand All @@ -142,7 +154,7 @@ def add_item(self, title, link, description, author_email=None,
'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,
Expand Down Expand Up @@ -317,18 +329,27 @@ def add_item_elements(self, handler, item):
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']:
handler.addQuickElement("category", cat)


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"

Expand Down Expand Up @@ -405,13 +426,14 @@ def add_item_elements(self, handler, item):
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 []:

This comment has been minimized.

Copy link
@berkerpeksag

berkerpeksag Sep 18, 2015

Contributor

or item.get('enclosures', [])

This comment has been minimized.

Copy link
@umazalakain

umazalakain Sep 19, 2015

But say that item = {'enclosures': None}. Then:

item.get('enclosures', []) != item.get('enclosures') or []

The little quirk protects the for loop against that (unlikely) possibility.

This comment has been minimized.

Copy link
@timgraham

timgraham Sep 19, 2015

Member

Could we add a comment or a test to prevent someone from coming along and "cleaning up" the code and not realizing this?

This comment has been minimized.

Copy link
@umazalakain

umazalakain Sep 19, 2015

Currently there is no way a user could get item['enclosures'] to be None using the public API (it will always be a list). That said, I think it is still nice to be protected against it (the add_item method could change and allow item['enclosures'] == None in the future). Seeing there is no way the public API can get someone there, perhaps adding a comment would be enough?

This comment has been minimized.

Copy link
@umazalakain

umazalakain Sep 19, 2015

Something like:

for enclosure in item.get('enclosures') or []:  # Protect against a possible item['enclosures'] == None

This comment has been minimized.

Copy link
@timgraham

timgraham Sep 19, 2015

Member

Is it different from the categories loop which doesn't have similar protection?

This comment has been minimized.

Copy link
@umazalakain

umazalakain Sep 19, 2015

No it's not. Should we perhaps simply assume that the enclosures key is always set and always has an iterable in it?

This comment has been minimized.

Copy link
@timgraham

timgraham Sep 19, 2015

Member

Yes, I think keeping it simple is okay.

handler.addQuickElement('link', '', {
'rel': 'enclosure',
'href': enclosure.url,
'length': enclosure.length,
'type': enclosure.mime_type,
})

# Categories.
for cat in item['categories']:
Expand Down
3 changes: 3 additions & 0 deletions docs/internals/deprecation.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 42 additions & 6 deletions docs/ref/contrib/syndication.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------

Expand Down Expand Up @@ -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):
"""
Expand All @@ -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):
"""
Expand All @@ -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):
"""
Expand Down Expand Up @@ -941,6 +969,7 @@ They share this interface:
* ``comments``
* ``unique_id``
* ``enclosure``
* ``enclosures``
* ``categories``
* ``item_copyright``
* ``ttl``
Expand All @@ -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.

Expand Down
11 changes: 9 additions & 2 deletions docs/ref/utils.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 7 additions & 1 deletion docs/releases/1.9.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^
Expand Down Expand Up @@ -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
Expand Down
50 changes: 48 additions & 2 deletions tests/syndication_tests/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,29 @@ def items(self):
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):
Expand Down Expand Up @@ -165,3 +186,28 @@ def add_item_elements(self, handler, item):

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

0 comments on commit aac2a2d

Please sign in to comment.