atom.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. # -*- coding: utf-8 -*-
  2. """
  3. werkzeug.contrib.atom
  4. ~~~~~~~~~~~~~~~~~~~~~
  5. This module provides a class called :class:`AtomFeed` which can be
  6. used to generate feeds in the Atom syndication format (see :rfc:`4287`).
  7. Example::
  8. def atom_feed(request):
  9. feed = AtomFeed("My Blog", feed_url=request.url,
  10. url=request.host_url,
  11. subtitle="My example blog for a feed test.")
  12. for post in Post.query.limit(10).all():
  13. feed.add(post.title, post.body, content_type='html',
  14. author=post.author, url=post.url, id=post.uid,
  15. updated=post.last_update, published=post.pub_date)
  16. return feed.get_response()
  17. :copyright: (c) 2014 by the Werkzeug Team, see AUTHORS for more details.
  18. :license: BSD, see LICENSE for more details.
  19. """
  20. from datetime import datetime
  21. from werkzeug.utils import escape
  22. from werkzeug.wrappers import BaseResponse
  23. from werkzeug._compat import implements_to_string, string_types
  24. XHTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'
  25. def _make_text_block(name, content, content_type=None):
  26. """Helper function for the builder that creates an XML text block."""
  27. if content_type == 'xhtml':
  28. return u'<%s type="xhtml"><div xmlns="%s">%s</div></%s>\n' % \
  29. (name, XHTML_NAMESPACE, content, name)
  30. if not content_type:
  31. return u'<%s>%s</%s>\n' % (name, escape(content), name)
  32. return u'<%s type="%s">%s</%s>\n' % (name, content_type,
  33. escape(content), name)
  34. def format_iso8601(obj):
  35. """Format a datetime object for iso8601"""
  36. iso8601 = obj.isoformat()
  37. if obj.tzinfo:
  38. return iso8601
  39. return iso8601 + 'Z'
  40. @implements_to_string
  41. class AtomFeed(object):
  42. """A helper class that creates Atom feeds.
  43. :param title: the title of the feed. Required.
  44. :param title_type: the type attribute for the title element. One of
  45. ``'html'``, ``'text'`` or ``'xhtml'``.
  46. :param url: the url for the feed (not the url *of* the feed)
  47. :param id: a globally unique id for the feed. Must be an URI. If
  48. not present the `feed_url` is used, but one of both is
  49. required.
  50. :param updated: the time the feed was modified the last time. Must
  51. be a :class:`datetime.datetime` object. If not
  52. present the latest entry's `updated` is used.
  53. Treated as UTC if naive datetime.
  54. :param feed_url: the URL to the feed. Should be the URL that was
  55. requested.
  56. :param author: the author of the feed. Must be either a string (the
  57. name) or a dict with name (required) and uri or
  58. email (both optional). Can be a list of (may be
  59. mixed, too) strings and dicts, too, if there are
  60. multiple authors. Required if not every entry has an
  61. author element.
  62. :param icon: an icon for the feed.
  63. :param logo: a logo for the feed.
  64. :param rights: copyright information for the feed.
  65. :param rights_type: the type attribute for the rights element. One of
  66. ``'html'``, ``'text'`` or ``'xhtml'``. Default is
  67. ``'text'``.
  68. :param subtitle: a short description of the feed.
  69. :param subtitle_type: the type attribute for the subtitle element.
  70. One of ``'text'``, ``'html'``, ``'text'``
  71. or ``'xhtml'``. Default is ``'text'``.
  72. :param links: additional links. Must be a list of dictionaries with
  73. href (required) and rel, type, hreflang, title, length
  74. (all optional)
  75. :param generator: the software that generated this feed. This must be
  76. a tuple in the form ``(name, url, version)``. If
  77. you don't want to specify one of them, set the item
  78. to `None`.
  79. :param entries: a list with the entries for the feed. Entries can also
  80. be added later with :meth:`add`.
  81. For more information on the elements see
  82. http://www.atomenabled.org/developers/syndication/
  83. Everywhere where a list is demanded, any iterable can be used.
  84. """
  85. default_generator = ('Werkzeug', None, None)
  86. def __init__(self, title=None, entries=None, **kwargs):
  87. self.title = title
  88. self.title_type = kwargs.get('title_type', 'text')
  89. self.url = kwargs.get('url')
  90. self.feed_url = kwargs.get('feed_url', self.url)
  91. self.id = kwargs.get('id', self.feed_url)
  92. self.updated = kwargs.get('updated')
  93. self.author = kwargs.get('author', ())
  94. self.icon = kwargs.get('icon')
  95. self.logo = kwargs.get('logo')
  96. self.rights = kwargs.get('rights')
  97. self.rights_type = kwargs.get('rights_type')
  98. self.subtitle = kwargs.get('subtitle')
  99. self.subtitle_type = kwargs.get('subtitle_type', 'text')
  100. self.generator = kwargs.get('generator')
  101. if self.generator is None:
  102. self.generator = self.default_generator
  103. self.links = kwargs.get('links', [])
  104. self.entries = entries and list(entries) or []
  105. if not hasattr(self.author, '__iter__') \
  106. or isinstance(self.author, string_types + (dict,)):
  107. self.author = [self.author]
  108. for i, author in enumerate(self.author):
  109. if not isinstance(author, dict):
  110. self.author[i] = {'name': author}
  111. if not self.title:
  112. raise ValueError('title is required')
  113. if not self.id:
  114. raise ValueError('id is required')
  115. for author in self.author:
  116. if 'name' not in author:
  117. raise TypeError('author must contain at least a name')
  118. def add(self, *args, **kwargs):
  119. """Add a new entry to the feed. This function can either be called
  120. with a :class:`FeedEntry` or some keyword and positional arguments
  121. that are forwarded to the :class:`FeedEntry` constructor.
  122. """
  123. if len(args) == 1 and not kwargs and isinstance(args[0], FeedEntry):
  124. self.entries.append(args[0])
  125. else:
  126. kwargs['feed_url'] = self.feed_url
  127. self.entries.append(FeedEntry(*args, **kwargs))
  128. def __repr__(self):
  129. return '<%s %r (%d entries)>' % (
  130. self.__class__.__name__,
  131. self.title,
  132. len(self.entries)
  133. )
  134. def generate(self):
  135. """Return a generator that yields pieces of XML."""
  136. # atom demands either an author element in every entry or a global one
  137. if not self.author:
  138. if any(not e.author for e in self.entries):
  139. self.author = ({'name': 'Unknown author'},)
  140. if not self.updated:
  141. dates = sorted([entry.updated for entry in self.entries])
  142. self.updated = dates and dates[-1] or datetime.utcnow()
  143. yield u'<?xml version="1.0" encoding="utf-8"?>\n'
  144. yield u'<feed xmlns="http://www.w3.org/2005/Atom">\n'
  145. yield ' ' + _make_text_block('title', self.title, self.title_type)
  146. yield u' <id>%s</id>\n' % escape(self.id)
  147. yield u' <updated>%s</updated>\n' % format_iso8601(self.updated)
  148. if self.url:
  149. yield u' <link href="%s" />\n' % escape(self.url)
  150. if self.feed_url:
  151. yield u' <link href="%s" rel="self" />\n' % \
  152. escape(self.feed_url)
  153. for link in self.links:
  154. yield u' <link %s/>\n' % ''.join('%s="%s" ' %
  155. (k, escape(link[k])) for k in link)
  156. for author in self.author:
  157. yield u' <author>\n'
  158. yield u' <name>%s</name>\n' % escape(author['name'])
  159. if 'uri' in author:
  160. yield u' <uri>%s</uri>\n' % escape(author['uri'])
  161. if 'email' in author:
  162. yield ' <email>%s</email>\n' % escape(author['email'])
  163. yield ' </author>\n'
  164. if self.subtitle:
  165. yield ' ' + _make_text_block('subtitle', self.subtitle,
  166. self.subtitle_type)
  167. if self.icon:
  168. yield u' <icon>%s</icon>\n' % escape(self.icon)
  169. if self.logo:
  170. yield u' <logo>%s</logo>\n' % escape(self.logo)
  171. if self.rights:
  172. yield ' ' + _make_text_block('rights', self.rights,
  173. self.rights_type)
  174. generator_name, generator_url, generator_version = self.generator
  175. if generator_name or generator_url or generator_version:
  176. tmp = [u' <generator']
  177. if generator_url:
  178. tmp.append(u' uri="%s"' % escape(generator_url))
  179. if generator_version:
  180. tmp.append(u' version="%s"' % escape(generator_version))
  181. tmp.append(u'>%s</generator>\n' % escape(generator_name))
  182. yield u''.join(tmp)
  183. for entry in self.entries:
  184. for line in entry.generate():
  185. yield u' ' + line
  186. yield u'</feed>\n'
  187. def to_string(self):
  188. """Convert the feed into a string."""
  189. return u''.join(self.generate())
  190. def get_response(self):
  191. """Return a response object for the feed."""
  192. return BaseResponse(self.to_string(), mimetype='application/atom+xml')
  193. def __call__(self, environ, start_response):
  194. """Use the class as WSGI response object."""
  195. return self.get_response()(environ, start_response)
  196. def __str__(self):
  197. return self.to_string()
  198. @implements_to_string
  199. class FeedEntry(object):
  200. """Represents a single entry in a feed.
  201. :param title: the title of the entry. Required.
  202. :param title_type: the type attribute for the title element. One of
  203. ``'html'``, ``'text'`` or ``'xhtml'``.
  204. :param content: the content of the entry.
  205. :param content_type: the type attribute for the content element. One
  206. of ``'html'``, ``'text'`` or ``'xhtml'``.
  207. :param summary: a summary of the entry's content.
  208. :param summary_type: the type attribute for the summary element. One
  209. of ``'html'``, ``'text'`` or ``'xhtml'``.
  210. :param url: the url for the entry.
  211. :param id: a globally unique id for the entry. Must be an URI. If
  212. not present the URL is used, but one of both is required.
  213. :param updated: the time the entry was modified the last time. Must
  214. be a :class:`datetime.datetime` object. Treated as
  215. UTC if naive datetime. Required.
  216. :param author: the author of the entry. Must be either a string (the
  217. name) or a dict with name (required) and uri or
  218. email (both optional). Can be a list of (may be
  219. mixed, too) strings and dicts, too, if there are
  220. multiple authors. Required if the feed does not have an
  221. author element.
  222. :param published: the time the entry was initially published. Must
  223. be a :class:`datetime.datetime` object. Treated as
  224. UTC if naive datetime.
  225. :param rights: copyright information for the entry.
  226. :param rights_type: the type attribute for the rights element. One of
  227. ``'html'``, ``'text'`` or ``'xhtml'``. Default is
  228. ``'text'``.
  229. :param links: additional links. Must be a list of dictionaries with
  230. href (required) and rel, type, hreflang, title, length
  231. (all optional)
  232. :param categories: categories for the entry. Must be a list of dictionaries
  233. with term (required), scheme and label (all optional)
  234. :param xml_base: The xml base (url) for this feed item. If not provided
  235. it will default to the item url.
  236. For more information on the elements see
  237. http://www.atomenabled.org/developers/syndication/
  238. Everywhere where a list is demanded, any iterable can be used.
  239. """
  240. def __init__(self, title=None, content=None, feed_url=None, **kwargs):
  241. self.title = title
  242. self.title_type = kwargs.get('title_type', 'text')
  243. self.content = content
  244. self.content_type = kwargs.get('content_type', 'html')
  245. self.url = kwargs.get('url')
  246. self.id = kwargs.get('id', self.url)
  247. self.updated = kwargs.get('updated')
  248. self.summary = kwargs.get('summary')
  249. self.summary_type = kwargs.get('summary_type', 'html')
  250. self.author = kwargs.get('author', ())
  251. self.published = kwargs.get('published')
  252. self.rights = kwargs.get('rights')
  253. self.links = kwargs.get('links', [])
  254. self.categories = kwargs.get('categories', [])
  255. self.xml_base = kwargs.get('xml_base', feed_url)
  256. if not hasattr(self.author, '__iter__') \
  257. or isinstance(self.author, string_types + (dict,)):
  258. self.author = [self.author]
  259. for i, author in enumerate(self.author):
  260. if not isinstance(author, dict):
  261. self.author[i] = {'name': author}
  262. if not self.title:
  263. raise ValueError('title is required')
  264. if not self.id:
  265. raise ValueError('id is required')
  266. if not self.updated:
  267. raise ValueError('updated is required')
  268. def __repr__(self):
  269. return '<%s %r>' % (
  270. self.__class__.__name__,
  271. self.title
  272. )
  273. def generate(self):
  274. """Yields pieces of ATOM XML."""
  275. base = ''
  276. if self.xml_base:
  277. base = ' xml:base="%s"' % escape(self.xml_base)
  278. yield u'<entry%s>\n' % base
  279. yield u' ' + _make_text_block('title', self.title, self.title_type)
  280. yield u' <id>%s</id>\n' % escape(self.id)
  281. yield u' <updated>%s</updated>\n' % format_iso8601(self.updated)
  282. if self.published:
  283. yield u' <published>%s</published>\n' % \
  284. format_iso8601(self.published)
  285. if self.url:
  286. yield u' <link href="%s" />\n' % escape(self.url)
  287. for author in self.author:
  288. yield u' <author>\n'
  289. yield u' <name>%s</name>\n' % escape(author['name'])
  290. if 'uri' in author:
  291. yield u' <uri>%s</uri>\n' % escape(author['uri'])
  292. if 'email' in author:
  293. yield u' <email>%s</email>\n' % escape(author['email'])
  294. yield u' </author>\n'
  295. for link in self.links:
  296. yield u' <link %s/>\n' % ''.join('%s="%s" ' %
  297. (k, escape(link[k])) for k in link)
  298. for category in self.categories:
  299. yield u' <category %s/>\n' % ''.join('%s="%s" ' %
  300. (k, escape(category[k])) for k in category)
  301. if self.summary:
  302. yield u' ' + _make_text_block('summary', self.summary,
  303. self.summary_type)
  304. if self.content:
  305. yield u' ' + _make_text_block('content', self.content,
  306. self.content_type)
  307. yield u'</entry>\n'
  308. def to_string(self):
  309. """Convert the feed item into a unicode object."""
  310. return u''.join(self.generate())
  311. def __str__(self):
  312. return self.to_string()