2
# django-atompub by James Tauber <http://jtauber.com/>
3
# http://code.google.com/p/django-atompub/
4
# An implementation of the Atom format and protocol for Django
6
# For instructions on how to use this module to generate Atom feeds,
7
# see http://code.google.com/p/django-atompub/wiki/UserGuide
10
# Copyright (c) 2007, James Tauber
12
# Permission is hereby granted, free of charge, to any person obtaining a copy
13
# of this software and associated documentation files (the "Software"), to deal
14
# in the Software without restriction, including without limitation the rights
15
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
# copies of the Software, and to permit persons to whom the Software is
17
# furnished to do so, subject to the following conditions:
19
# The above copyright notice and this permission notice shall be included in
20
# all copies or substantial portions of the Software.
22
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
31
from xml.sax.saxutils import XMLGenerator
32
from datetime import datetime
35
GENERATOR_TEXT = 'django-atompub'
37
'uri': 'http://code.google.com/p/django-atompub/',
43
## based on django.utils.xmlutils.SimplerXMLGenerator
44
class SimplerXMLGenerator(XMLGenerator):
45
def addQuickElement(self, name, contents=None, attrs=None):
46
"Convenience method for adding an element with no children"
47
if attrs is None: attrs = {}
48
self.startElement(name, attrs)
49
if contents is not None:
50
self.characters(contents)
55
## based on django.utils.feedgenerator.rfc3339_date
56
def rfc3339_date(date):
57
return date.strftime('%Y-%m-%dT%H:%M:%SZ')
61
## based on django.utils.feedgenerator.get_tag_uri
62
def get_tag_uri(url, date):
63
"Creates a TagURI. See http://diveintomark.org/archives/2004/05/28/howto-atom-id"
64
tag = re.sub('^http://', '', url)
66
tag = re.sub('/', ',%s:/' % date.strftime('%Y-%m-%d'), tag, 1)
67
tag = re.sub('#', '/', tag)
72
## based on django.contrib.syndication.feeds.Feed
79
def __init__(self, slug, feed_url):
80
# @@@ slug and feed_url are not used yet
84
def __get_dynamic_attr(self, attname, obj, default=None):
86
attr = getattr(self, attname)
87
except AttributeError:
90
# Check func_code.co_argcount rather than try/excepting the
91
# function and catching the TypeError, because something inside
92
# the function may raise the TypeError. This technique is more
94
if hasattr(attr, 'func_code'):
95
argcount = attr.func_code.co_argcount
97
argcount = attr.__call__.func_code.co_argcount
98
if argcount == 2: # one argument is 'self'
105
def get_feed(self, extra_params=None):
109
obj = self.get_object(extra_params.split('/'))
110
except (AttributeError, LookupError):
111
raise LookupError('Feed does not exist')
116
atom_id = self.__get_dynamic_attr('feed_id', obj),
117
title = self.__get_dynamic_attr('feed_title', obj),
118
updated = self.__get_dynamic_attr('feed_updated', obj),
119
icon = self.__get_dynamic_attr('feed_icon', obj),
120
logo = self.__get_dynamic_attr('feed_logo', obj),
121
rights = self.__get_dynamic_attr('feed_rights', obj),
122
subtitle = self.__get_dynamic_attr('feed_subtitle', obj),
123
authors = self.__get_dynamic_attr('feed_authors', obj, default=[]),
124
categories = self.__get_dynamic_attr('feed_categories', obj, default=[]),
125
contributors = self.__get_dynamic_attr('feed_contributors', obj, default=[]),
126
links = self.__get_dynamic_attr('feed_links', obj, default=[]),
127
extra_attrs = self.__get_dynamic_attr('feed_extra_attrs', obj),
128
hide_generator = self.__get_dynamic_attr('hide_generator', obj, default=False)
131
items = self.__get_dynamic_attr('items', obj)
133
raise LookupError('Feed has no items field')
137
atom_id = self.__get_dynamic_attr('item_id', item),
138
title = self.__get_dynamic_attr('item_title', item),
139
updated = self.__get_dynamic_attr('item_updated', item),
140
content = self.__get_dynamic_attr('item_content', item),
141
published = self.__get_dynamic_attr('item_published', item),
142
rights = self.__get_dynamic_attr('item_rights', item),
143
source = self.__get_dynamic_attr('item_source', item),
144
summary = self.__get_dynamic_attr('item_summary', item),
145
authors = self.__get_dynamic_attr('item_authors', item, default=[]),
146
categories = self.__get_dynamic_attr('item_categories', item, default=[]),
147
contributors = self.__get_dynamic_attr('item_contributors', item, default=[]),
148
links = self.__get_dynamic_attr('item_links', item, default=[]),
149
extra_attrs = self.__get_dynamic_attr('item_extra_attrs', None, default={}),
158
class ValidationError(Exception):
163
## based on django.utils.feedgenerator.SyndicationFeed and django.utils.feedgenerator.Atom1Feed
164
class AtomFeed(object):
167
mime_type = 'application/atom+xml'
168
ns = u'http://www.w3.org/2005/Atom'
171
def __init__(self, atom_id, title, updated=None, icon=None, logo=None, rights=None, subtitle=None,
172
authors=[], categories=[], contributors=[], links=[], extra_attrs={}, hide_generator=False):
174
raise LookupError('Feed has no feed_id field')
176
raise LookupError('Feed has no feed_title field')
177
# if updated == None, we'll calculate it
185
'subtitle': subtitle,
187
'categories': categories,
188
'contributors': contributors,
190
'extra_attrs': extra_attrs,
191
'hide_generator': hide_generator,
196
def add_item(self, atom_id, title, updated, content=None, published=None, rights=None, source=None, summary=None,
197
authors=[], categories=[], contributors=[], links=[], extra_attrs={}):
199
raise LookupError('Feed has no item_id method')
201
raise LookupError('Feed has no item_title method')
203
raise LookupError('Feed has no item_updated method')
209
'published': published,
214
'categories': categories,
215
'contributors': contributors,
217
'extra_attrs': extra_attrs,
221
def latest_updated(self):
223
Returns the latest item's updated or the current time if there are no items.
225
updates = [item['updated'] for item in self.items]
230
return datetime.now() # @@@ really we should allow a feed to define its "start" for this case
233
def write_text_construct(self, handler, element_name, data):
234
if isinstance(data, tuple):
235
text_type, text = data
236
if text_type == 'xhtml':
237
handler.startElement(element_name, {'type': text_type})
238
handler._write(text) # write unescaped -- it had better be well-formed XML
239
handler.endElement(element_name)
241
handler.addQuickElement(element_name, text, {'type': text_type})
243
handler.addQuickElement(element_name, data)
246
def write_person_construct(self, handler, element_name, person):
247
handler.startElement(element_name, {})
248
handler.addQuickElement(u'name', person['name'])
250
handler.addQuickElement(u'uri', person['uri'])
251
if 'email' in person:
252
handler.addQuickElement(u'email', person['email'])
253
handler.endElement(element_name)
256
def write_link_construct(self, handler, link):
258
link['length'] = str(link['length'])
259
handler.addQuickElement(u'link', None, link)
262
def write_category_construct(self, handler, category):
263
handler.addQuickElement(u'category', None, category)
266
def write_source(self, handler, data):
267
handler.startElement(u'source', {})
269
handler.addQuickElement(u'id', data['id'])
270
if data.get('title'):
271
self.write_text_construct(handler, u'title', data['title'])
272
if data.get('subtitle'):
273
self.write_text_construct(handler, u'subtitle', data['subtitle'])
275
handler.addQuickElement(u'icon', data['icon'])
277
handler.addQuickElement(u'logo', data['logo'])
278
if data.get('updated'):
279
handler.addQuickElement(u'updated', rfc3339_date(data['updated']))
280
for category in data.get('categories', []):
281
self.write_category_construct(handler, category)
282
for link in data.get('links', []):
283
self.write_link_construct(handler, link)
284
for author in data.get('authors', []):
285
self.write_person_construct(handler, u'author', author)
286
for contributor in data.get('contributors', []):
287
self.write_person_construct(handler, u'contributor', contributor)
288
if data.get('rights'):
289
self.write_text_construct(handler, u'rights', data['rights'])
290
handler.endElement(u'source')
293
def write_content(self, handler, data):
294
if isinstance(data, tuple):
295
content_dict, text = data
296
if content_dict.get('type') == 'xhtml':
297
handler.startElement(u'content', content_dict)
298
handler._write(text) # write unescaped -- it had better be well-formed XML
299
handler.endElement(u'content')
301
handler.addQuickElement(u'content', text, content_dict)
303
handler.addQuickElement(u'content', data)
306
def write(self, outfile, encoding):
307
handler = SimplerXMLGenerator(outfile, encoding)
308
handler.startDocument()
309
feed_attrs = {u'xmlns': self.ns}
310
if self.feed.get('extra_attrs'):
311
feed_attrs.update(self.feed['extra_attrs'])
312
handler.startElement(u'feed', feed_attrs)
313
handler.addQuickElement(u'id', self.feed['id'])
314
self.write_text_construct(handler, u'title', self.feed['title'])
315
if self.feed.get('subtitle'):
316
self.write_text_construct(handler, u'subtitle', self.feed['subtitle'])
317
if self.feed.get('icon'):
318
handler.addQuickElement(u'icon', self.feed['icon'])
319
if self.feed.get('logo'):
320
handler.addQuickElement(u'logo', self.feed['logo'])
321
if self.feed['updated']:
322
handler.addQuickElement(u'updated', rfc3339_date(self.feed['updated']))
324
handler.addQuickElement(u'updated', rfc3339_date(self.latest_updated()))
325
for category in self.feed['categories']:
326
self.write_category_construct(handler, category)
327
for link in self.feed['links']:
328
self.write_link_construct(handler, link)
329
for author in self.feed['authors']:
330
self.write_person_construct(handler, u'author', author)
331
for contributor in self.feed['contributors']:
332
self.write_person_construct(handler, u'contributor', contributor)
333
if self.feed.get('rights'):
334
self.write_text_construct(handler, u'rights', self.feed['rights'])
335
if not self.feed.get('hide_generator'):
336
handler.addQuickElement(u'generator', GENERATOR_TEXT, GENERATOR_ATTR)
338
self.write_items(handler)
340
handler.endElement(u'feed')
343
def write_items(self, handler):
344
for item in self.items:
345
entry_attrs = item.get('extra_attrs', {})
346
handler.startElement(u'entry', entry_attrs)
348
handler.addQuickElement(u'id', item['id'])
349
self.write_text_construct(handler, u'title', item['title'])
350
handler.addQuickElement(u'updated', rfc3339_date(item['updated']))
351
if item.get('published'):
352
handler.addQuickElement(u'published', rfc3339_date(item['published']))
353
if item.get('rights'):
354
self.write_text_construct(handler, u'rights', item['rights'])
355
if item.get('source'):
356
self.write_source(handler, item['source'])
358
for author in item['authors']:
359
self.write_person_construct(handler, u'author', author)
360
for contributor in item['contributors']:
361
self.write_person_construct(handler, u'contributor', contributor)
362
for category in item['categories']:
363
self.write_category_construct(handler, category)
364
for link in item['links']:
365
self.write_link_construct(handler, link)
366
if item.get('summary'):
367
self.write_text_construct(handler, u'summary', item['summary'])
368
if item.get('content'):
369
self.write_content(handler, item['content'])
371
handler.endElement(u'entry')
376
def validate_text_construct(obj):
377
if isinstance(obj, tuple):
378
if obj[0] not in ['text', 'html', 'xhtml']:
380
# @@@ no validation is done that 'html' text constructs are valid HTML
381
# @@@ no validation is done that 'xhtml' text constructs are well-formed XML or valid XHTML
385
if not validate_text_construct(self.feed['title']):
386
raise ValidationError('feed title has invalid type')
387
if self.feed.get('subtitle'):
388
if not validate_text_construct(self.feed['subtitle']):
389
raise ValidationError('feed subtitle has invalid type')
390
if self.feed.get('rights'):
391
if not validate_text_construct(self.feed['rights']):
392
raise ValidationError('feed rights has invalid type')
395
for link in self.feed.get('links'):
396
if link.get('rel') == 'alternate' or link.get('rel') == None:
397
key = (link.get('type'), link.get('hreflang'))
398
if key in alternate_links:
399
raise ValidationError('alternate links must have unique type/hreflang')
400
alternate_links[key] = link
402
if self.feed.get('authors'):
407
for item in self.items:
408
if not feed_author and not item.get('authors'):
409
if item.get('source') and item['source'].get('authors'):
412
raise ValidationError('if no feed author, all entries must have author (possibly in source)')
414
if not validate_text_construct(item['title']):
415
raise ValidationError('entry title has invalid type')
416
if item.get('rights'):
417
if not validate_text_construct(item['rights']):
418
raise ValidationError('entry rights has invalid type')
419
if item.get('summary'):
420
if not validate_text_construct(item['summary']):
421
raise ValidationError('entry summary has invalid type')
422
source = item.get('source')
424
if source.get('title'):
425
if not validate_text_construct(source['title']):
426
raise ValidationError('source title has invalid type')
427
if source.get('subtitle'):
428
if not validate_text_construct(source['subtitle']):
429
raise ValidationError('source subtitle has invalid type')
430
if source.get('rights'):
431
if not validate_text_construct(source['rights']):
432
raise ValidationError('source rights has invalid type')
435
for link in item.get('links'):
436
if link.get('rel') == 'alternate' or link.get('rel') == None:
437
key = (link.get('type'), link.get('hreflang'))
438
if key in alternate_links:
439
raise ValidationError('alternate links must have unique type/hreflang')
440
alternate_links[key] = link
442
if not item.get('content'):
443
if not alternate_links:
444
raise ValidationError('if no content, entry must have alternate link')
446
if item.get('content') and isinstance(item.get('content'), tuple):
447
content_type = item.get('content')[0].get('type')
448
if item.get('content')[0].get('src'):
449
if item.get('content')[1]:
450
raise ValidationError('content with src should be empty')
451
if not item.get('summary'):
452
raise ValidationError('content with src requires a summary too')
453
if content_type in ['text', 'html', 'xhtml']:
454
raise ValidationError('content with src cannot have type of text, html or xhtml')
456
if '/' in content_type and \
457
not content_type.startswith('text/') and \
458
not content_type.endswith('/xml') and not content_type.endswith('+xml') and \
459
not content_type in ['application/xml-external-parsed-entity', 'application/xml-dtd']:
460
# @@@ check content is Base64
461
if not item.get('summary'):
462
raise ValidationError('content in Base64 requires a summary too')
463
if content_type not in ['text', 'html', 'xhtml'] and '/' not in content_type:
464
raise ValidationError('content type does not appear to be valid')
466
# @@@ no validation is done that 'html' text constructs are valid HTML
467
# @@@ no validation is done that 'xhtml' text constructs are well-formed XML or valid XHTML
475
class LegacySyndicationFeed(AtomFeed):
477
Provides an SyndicationFeed-compatible interface in its __init__ and
478
add_item but is really a new AtomFeed object.
481
def __init__(self, title, link, description, language=None, author_email=None,
482
author_name=None, author_link=None, subtitle=None, categories=[],
483
feed_url=None, feed_copyright=None):
487
updated = None # will be calculated
488
rights = feed_copyright
490
author_dict = {'name': author_name}
492
author_dict['uri'] = author_uri
494
author_dict['email'] = author_email
495
authors = [author_dict]
497
categories = [{'term': term} for term in categories]
498
links = [{'rel': 'alternate', 'href': link}]
500
links.append({'rel': 'self', 'href': feed_url})
502
extra_attrs = {'xml:lang': language}
506
# description ignored (as with Atom1Feed)
508
AtomFeed.__init__(self, atom_id, title, updated, rights=rights, subtitle=subtitle,
509
authors=authors, categories=categories, links=links, extra_attrs=extra_attrs)
512
def add_item(self, title, link, description, author_email=None,
513
author_name=None, author_link=None, pubdate=None, comments=None,
514
unique_id=None, enclosure=None, categories=[], item_copyright=None):
519
atom_id = get_tag_uri(link, pubdate)
523
rights = item_copyright
527
summary = 'html', description
530
author_dict = {'name': author_name}
532
author_dict['uri'] = author_uri
534
author_dict['email'] = author_email
535
authors = [author_dict]
536
categories = [{'term': term} for term in categories]
537
links = [{'rel': 'alternate', 'href': link}]
539
links.append({'rel': 'enclosure', 'href': enclosure.url, 'length': enclosure.length, 'type': enclosure.mime_type})
541
AtomFeed.add_item(self, atom_id, title, updated, rights=rights, summary=summary,
542
authors=authors, categories=categories, links=links)