diff --git a/Makefile b/Makefile index 71bc310..5eca053 100644 --- a/Makefile +++ b/Makefile @@ -53,3 +53,4 @@ test: python -m unittest tests.test_entry python -m unittest tests.test_extension @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml + flake8 $$(find setup.py tests feedgen -name '*.py') diff --git a/doc/conf.py b/doc/conf.py index ac1eb8f..5495c4a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -3,7 +3,10 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os, time, codecs, re +import sys +import os +import codecs +import re # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -13,18 +16,18 @@ sys.path.insert(0, os.path.abspath('.')) import feedgen.version -# -- General configuration ----------------------------------------------------- +# -- General configuration ---------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ - 'sphinx.ext.intersphinx', - 'sphinx.ext.coverage', - 'sphinx.ext.autodoc' - ] + 'sphinx.ext.intersphinx', + 'sphinx.ext.coverage', + 'sphinx.ext.autodoc' + ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -65,7 +68,8 @@ release = feedgen.version.version_full_str # directories to ignore when looking for source files. exclude_patterns = ['_build'] -# The reST default role (used for this markup: `text`) to use for all documents. +# The reST default role (used for this markup: `text`) to use for all +# documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. @@ -86,7 +90,7 @@ pygments_style = 'sphinx' #modindex_common_prefix = [] -# -- Options for HTML output --------------------------------------------------- +# -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. @@ -169,24 +173,24 @@ html_static_path = ['_static'] htmlhelp_basename = 'pyFeedGen' -# -- Options for LaTeX output -------------------------------------------------- +# -- Options for LaTeX output ------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). +# Grouping the document tree into LaTeX files. List of tuples (source start +# file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', - u'Lars Kiesow', 'manual'), + ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', u'Lars Kiesow', + 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -210,28 +214,28 @@ latex_documents = [ #latex_domain_indices = True -# -- Options for manual page output -------------------------------------------- +# -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', - [u'Lars Kiesow'], 1) + ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', + [u'Lars Kiesow'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False -# -- Options for Texinfo output ------------------------------------------------ +# -- Options for Texinfo output ----------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', - u'Lars Kiesow', 'Lernfunk3', 'One line description of project.', - 'Miscellaneous'), + ('index', 'pyFeedGen.tex', u'pyFeedGen Documentation', + u'Lars Kiesow', 'Lernfunk3', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. @@ -248,36 +252,18 @@ texinfo_documents = [ intersphinx_mapping = {'http://docs.python.org/': None} -# Ugly way of setting tabsize -import re - -def process_docstring(app, what, name, obj, options, lines): - ''' - spaces_pat = re.compile(r"(?<= )( {8})") - ll = [] - for l in lines: - ll.append(spaces_pat.sub(" ",l)) - ''' - spaces_pat = re.compile(r"^ *") - ll = [] - for l in lines: - spacelen = len(spaces_pat.search(l).group(0)) - newlen = (spacelen % 8) + (spacelen / 8 * 4) - ll.append( (' '*newlen) + l.lstrip(' ') ) - lines[:] = ll - - # Include the GitHub readme file in index.rst r = re.compile(r'\[`*([^\]`]+)`*\]\(([^\)]+)\)') r2 = re.compile(r'.. include-github-readme') + + def substitute_link(app, docname, text): - if docname == 'index': - readme_text = '' - with codecs.open(os.path.abspath('../readme.md'), 'r', 'utf-8') as f: - readme_text = r.sub(r'`\1 <\2>`_', f.read()) - text[0] = r2.sub(readme_text, text[0]) + if docname == 'index': + readme_text = '' + with codecs.open(os.path.abspath('../readme.md'), 'r', 'utf-8') as f: + readme_text = r.sub(r'`\1 <\2>`_', f.read()) + text[0] = r2.sub(readme_text, text[0]) def setup(app): - app.connect('autodoc-process-docstring', process_docstring) - app.connect('source-read', substitute_link) + app.connect('source-read', substitute_link) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index aef2c72..ab98e93 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -1,136 +1,137 @@ # -*- coding: utf-8 -*- """ - ======= - feedgen - ======= + ======= + feedgen + ======= - This module can be used to generate web feeds in both ATOM and RSS format. - It has support for extensions. Included is for example an extension to - produce Podcasts. + This module can be used to generate web feeds in both ATOM and RSS format. + It has support for extensions. Included is for example an extension to + produce Podcasts. - :copyright: 2013 by Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :copyright: 2013 by Lars Kiesow + :license: FreeBSD and LGPL, see license.* for more details. - ------------- - Create a Feed - ------------- + ------------- + Create a Feed + ------------- - To create a feed simply instanciate the FeedGenerator class and insert some - data:: + To create a feed simply instanciate the FeedGenerator class and insert some + data:: - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.id('http://lernfunk.de/media/654321') - >>> fg.title('Some Testfeed') - >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) - >>> fg.link( href='http://example.com', rel='alternate' ) - >>> fg.logo('http://ex.com/logo.jpg') - >>> fg.subtitle('This is a cool feed!') - >>> fg.link( href='http://larskiesow.de/test.atom', rel='self' ) - >>> fg.language('en') + >>> from feedgen.feed import FeedGenerator + >>> fg = FeedGenerator() + >>> fg.id('http://lernfunk.de/media/654321') + >>> fg.title('Some Testfeed') + >>> fg.author( {'name':'John Doe','email':'john@example.de'} ) + >>> fg.link( href='http://example.com', rel='alternate' ) + >>> fg.logo('http://ex.com/logo.jpg') + >>> fg.subtitle('This is a cool feed!') + >>> fg.link( href='http://larskiesow.de/test.atom', rel='self' ) + >>> fg.language('en') - Note that for the methods which set fields that can occur more than once in - a feed you can use all of the following ways to provide data: + Note that for the methods which set fields that can occur more than once in + a feed you can use all of the following ways to provide data: - - Provide the data for that element as keyword arguments - - Provide the data for that element as dictionary - - Provide a list of dictionaries with the data for several elements + - Provide the data for that element as keyword arguments + - Provide the data for that element as dictionary + - Provide a list of dictionaries with the data for several elements - Example:: + Example:: - >>> fg.contributor( name='John Doe', email='jdoe@example.com' ) - >>> fg.contributor({'name':'John Doe', 'email':'jdoe@example.com'}) - >>> fg.contributor([{'name':'John Doe', 'email':'jdoe@example.com'}, ...]) + >>> fg.contributor(name='John Doe', email='jdoe@example.com' ) + >>> fg.contributor({'name':'John Doe', 'email':'jdoe@example.com'}) + >>> fg.contributor([{'name':'John', 'email':'jdoe@example.com'}, …]) - ----------------- - Generate the Feed - ----------------- + ----------------- + Generate the Feed + ----------------- - After that you can generate both RSS or ATOM by calling the respective method:: + After that you can generate both RSS or ATOM by calling the respective + method:: - >>> atomfeed = fg.atom_str(pretty=True) # Get the ATOM feed as string - >>> rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string - >>> fg.atom_file('atom.xml') # Write the ATOM feed to a file - >>> fg.rss_file('rss.xml') # Write the RSS feed to a file + >>> atomfeed = fg.atom_str(pretty=True) # Get the ATOM feed as string + >>> rssfeed = fg.rss_str(pretty=True) # Get the RSS feed as string + >>> fg.atom_file('atom.xml') # Write the ATOM feed to a file + >>> fg.rss_file('rss.xml') # Write the RSS feed to a file - ---------------- - Add Feed Entries - ---------------- + ---------------- + Add Feed Entries + ---------------- - To add entries (items) to a feed you need to create new FeedEntry objects - and append them to the list of entries in the FeedGenerator. The most - convenient way to go is to use the FeedGenerator itself for the - instantiation of the FeedEntry object:: + To add entries (items) to a feed you need to create new FeedEntry objects + and append them to the list of entries in the FeedGenerator. The most + convenient way to go is to use the FeedGenerator itself for the + instantiation of the FeedEntry object:: - >>> fe = fg.add_entry() - >>> fe.id('http://lernfunk.de/media/654321/1') - >>> fe.title('The First Episode') + >>> fe = fg.add_entry() + >>> fe.id('http://lernfunk.de/media/654321/1') + >>> fe.title('The First Episode') - The FeedGenerators method add_entry(...) without argument provides will - automatically generate a new FeedEntry object, append it to the feeds - internal list of entries and return it, so that additional data can be - added. + The FeedGenerators method add_entry(...) without argument provides will + automatically generate a new FeedEntry object, append it to the feeds + internal list of entries and return it, so that additional data can be + added. - ---------- - Extensions - ---------- + ---------- + Extensions + ---------- - The FeedGenerator supports extension to include additional data into the XML - structure of the feeds. Extensions can be loaded like this:: + The FeedGenerator supports extension to include additional data into the + XML structure of the feeds. Extensions can be loaded like this:: - >>> fg.load_extension('someext', atom=True, rss=True) + >>> fg.load_extension('someext', atom=True, rss=True) - This will try to load the extension “someext” from the file - `ext/someext.py`. It is required that `someext.py` contains a class named - “SomextExtension” which is required to have at least the two methods - `extend_rss(...)` and `extend_atom(...)`. Although not required, it is - strongly suggested to use BaseExtension from `ext/base.py` as superclass. + This will try to load the extension “someext” from the file + `ext/someext.py`. It is required that `someext.py` contains a class named + “SomextExtension” which is required to have at least the two methods + `extend_rss(...)` and `extend_atom(...)`. Although not required, it is + strongly suggested to use BaseExtension from `ext/base.py` as superclass. - `load_extension('someext', ...)` will also try to load a class named - “SomextEntryExtension” for every entry of the feed. This class can be - located either in the same file as SomextExtension or in - `ext/someext_entry.py` which is suggested especially for large extensions. + `load_extension('someext', ...)` will also try to load a class named + “SomextEntryExtension” for every entry of the feed. This class can be + located either in the same file as SomextExtension or in + `ext/someext_entry.py` which is suggested especially for large extensions. - The parameters `atom` and `rss` tell the FeedGenerator if the extensions - should only be used for either ATOM or RSS feeds. The default value for both - parameters is true which means that the extension would be used for both - kinds of feeds. + The parameters `atom` and `rss` tell the FeedGenerator if the extensions + should only be used for either ATOM or RSS feeds. The default value for + both parameters is true which means that the extension would be used for + both kinds of feeds. - **Example: Produceing a Podcast** + **Example: Produceing a Podcast** - One extension already provided is the podcast extension. A podcast is an RSS - feed with some additional elements for ITunes. + One extension already provided is the podcast extension. A podcast is an + RSS feed with some additional elements for ITunes. - To produce a podcast simply load the `podcast` extension:: + To produce a podcast simply load the `podcast` extension:: - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.load_extension('podcast') - ... - >>> fg.podcast.itunes_category('Technology', 'Podcasting') - ... - >>> fg.rss_str(pretty=True) - >>> fg.rss_file('podcast.xml') + >>> from feedgen.feed import FeedGenerator + >>> fg = FeedGenerator() + >>> fg.load_extension('podcast') + ... + >>> fg.podcast.itunes_category('Technology', 'Podcasting') + ... + >>> fg.rss_str(pretty=True) + >>> fg.rss_file('podcast.xml') - Of cause the extension has to be loaded for the FeedEntry objects as well - but this is done automatically by the FeedGenerator for every feed entry if - the extension is loaded for the whole feed. You can, however, load an - extension for a specific FeedEntry by calling `load_extension(...)` on that - entry. But this is a rather uncommon use. + Of cause the extension has to be loaded for the FeedEntry objects as well + but this is done automatically by the FeedGenerator for every feed entry if + the extension is loaded for the whole feed. You can, however, load an + extension for a specific FeedEntry by calling `load_extension(...)` on that + entry. But this is a rather uncommon use. - Of cause you can still produce a normal ATOM or RSS feed, even if you have - loaded some plugins by temporary disabling them during the feed generation. - This can be done by calling the generating method with the keyword argument - `extensions` set to `False`. + Of cause you can still produce a normal ATOM or RSS feed, even if you have + loaded some plugins by temporary disabling them during the feed generation. + This can be done by calling the generating method with the keyword argument + `extensions` set to `False`. - --------------------- - Testing the Generator - --------------------- + --------------------- + Testing the Generator + --------------------- - You can test the module by simply executing:: + You can test the module by simply executing:: - $ python -m feedgen + $ python -m feedgen """ diff --git a/feedgen/__main__.py b/feedgen/__main__.py index ec2b779..f072895 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -1,129 +1,140 @@ # -*- coding: utf-8 -*- ''' - feedgen - ~~~~~~~ + feedgen + ~~~~~~~ - :copyright: 2013-2016, Lars Kiesow + :copyright: 2013-2016, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' from feedgen.feed import FeedGenerator import sys -def print_enc(s): - '''Print function compatible with both python2 and python3 accepting strings - and byte arrays. - ''' - if sys.version_info[0] >= 3: - print(s.decode('utf-8') if type(s) == type(b'') else s) - else: - print(s) +USAGE = ''' +Usage: python -m feedgen [OPTION] + +Use one of the following options: + +File options: + .atom -- Generate ATOM test feed + .rss -- Generate RSS test teed + +Stdout options: + atom -- Generate ATOM test output + rss -- Generate RSS test output + podcast -- Generate Podcast test output + dc.atom -- Generate DC extension test output (atom format) + dc.rss -- Generate DC extension test output (rss format) + syndication.atom -- Generate syndication extension test output (atom format) + syndication.rss -- Generate syndication extension test output (rss format) + torrent -- Generate Torrent test output + +''' + + +def print_enc(s): + '''Print function compatible with both python2 and python3 accepting strings + and byte arrays. + ''' + if sys.version_info[0] >= 3: + print(s.decode('utf-8') if isinstance(s, bytes) else s) + else: + print(s) if __name__ == '__main__': - if len(sys.argv) != 2 or not ( - sys.argv[1].endswith('rss') \ - or sys.argv[1].endswith('atom') \ - or sys.argv[1].endswith('torrent') \ - or sys.argv[1].endswith('podcast') ): - print_enc ('Usage: %s ( .atom | atom | .rss | rss | podcast | torrent )' % \ - 'python -m feedgen') - print_enc ('') - print_enc (' atom -- Generate ATOM test output and print it to stdout.') - print_enc (' rss -- Generate RSS test output and print it to stdout.') - print_enc (' .atom -- Generate ATOM test feed and write it to file.atom.') - print_enc (' .rss -- Generate RSS test teed and write it to file.rss.') - print_enc (' podcast -- Generate Podcast test output and print it to stdout.') - print_enc (' dc.atom -- Generate DC extension test output (atom format) and print it to stdout.') - print_enc (' dc.rss -- Generate DC extension test output (rss format) and print it to stdout.') - print_enc (' syndication.atom -- Generate syndication extension test output (atom format) and print it to stdout.') - print_enc (' syndication.rss -- Generate syndication extension test output (rss format) and print it to stdout.') - print_enc (' torrent -- Generate Torrent test output and print it to stdout.') - print_enc ('') - exit() + if len(sys.argv) != 2 or not ( + sys.argv[1].endswith('rss') or + sys.argv[1].endswith('atom') or + sys.argv[1] == 'torrent' or + sys.argv[1] == 'podcast'): + print(USAGE) + exit() - arg = sys.argv[1] + arg = sys.argv[1] - fg = FeedGenerator() - fg.id('http://lernfunk.de/_MEDIAID_123') - fg.title('Testfeed') - fg.author( {'name':'Lars Kiesow','email':'lkiesow@uos.de'} ) - fg.link( href='http://example.com', rel='alternate' ) - fg.category(term='test') - fg.contributor( name='Lars Kiesow', email='lkiesow@uos.de' ) - fg.contributor( name='John Doe', email='jdoe@example.com' ) - fg.icon('http://ex.com/icon.jpg') - fg.logo('http://ex.com/logo.jpg') - fg.rights('cc-by') - fg.subtitle('This is a cool feed!') - fg.link( href='http://larskiesow.de/test.atom', rel='self' ) - fg.language('de') - fe = fg.add_entry() - fe.id('http://lernfunk.de/_MEDIAID_123#1') - fe.title('First Element') - fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen - aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si ista - mala sunt, placet. Aut etiam, ut vestitum, sic sententiam habeas aliam - domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas - occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, - verba.''') - fe.summary(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') - fe.link( href='http://example.com', rel='alternate' ) - fe.author( name='Lars Kiesow', email='lkiesow@uos.de' ) + fg = FeedGenerator() + fg.id('http://lernfunk.de/_MEDIAID_123') + fg.title('Testfeed') + fg.author({'name': 'Lars Kiesow', 'email': 'lkiesow@uos.de'}) + fg.link(href='http://example.com', rel='alternate') + fg.category(term='test') + fg.contributor(name='Lars Kiesow', email='lkiesow@uos.de') + fg.contributor(name='John Doe', email='jdoe@example.com') + fg.icon('http://ex.com/icon.jpg') + fg.logo('http://ex.com/logo.jpg') + fg.rights('cc-by') + fg.subtitle('This is a cool feed!') + fg.link(href='http://larskiesow.de/test.atom', rel='self') + fg.language('de') + fe = fg.add_entry() + fe.id('http://lernfunk.de/_MEDIAID_123#1') + fe.title('First Element') + fe.content('''Lorem ipsum dolor sit amet, consectetur adipiscing elit. Tamen + aberramus a proposito, et, ne longius, prorsus, inquam, Piso, si + ista mala sunt, placet. Aut etiam, ut vestitum, sic sententiam + habeas aliam domesticam, aliam forensem, ut in fronte ostentatio + sit, intus veritas occultetur? Cum id fugiunt, re eadem defendunt, + quae Peripatetici, verba.''') + fe.summary(u'Lorem ipsum dolor sit amet, consectetur adipiscing elit…') + fe.link(href='http://example.com', rel='alternate') + fe.author(name='Lars Kiesow', email='lkiesow@uos.de') - if arg == 'atom': - print_enc (fg.atom_str(pretty=True)) - elif arg == 'rss': - print_enc (fg.rss_str(pretty=True)) - elif arg == 'podcast': - # Load the podcast extension. It will automatically be loaded for all - # entries in the feed, too. Thus also for our “fe”. - fg.load_extension('podcast') - fg.podcast.itunes_author('Lars Kiesow') - fg.podcast.itunes_category('Technology', 'Podcasting') - fg.podcast.itunes_explicit('no') - fg.podcast.itunes_complete('no') - fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') - fg.podcast.itunes_owner('John Doe', 'john@example.com') - fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, ' + \ - 'consectetur adipiscing elit. ' + \ - 'Verba tu fingas et ea dicas, quae non sentias?') - fe.podcast.itunes_author('Lars Kiesow') - print_enc (fg.rss_str(pretty=True)) - - elif arg == 'torrent': - fg.load_extension('torrent') - fe.link( href='http://somewhere.behind.the.sea/torrent/debian-8.4.0-i386-netint.iso.torrent', rel='alternate', type='application/x-bittorrent, length=1000' ) - fe.torrent.filename('debian-8.4.0-i386-netint.iso.torrent') - fe.torrent.infohash('7661229811ef32014879ceedcdf4a48f256c88ba') - fe.torrent.contentlength('331350016') - fe.torrent.seeds('789') - fe.torrent.peers('456') - fe.torrent.verified('123') - print_enc (fg.rss_str(pretty=True)) + if arg == 'atom': + print_enc(fg.atom_str(pretty=True)) + elif arg == 'rss': + print_enc(fg.rss_str(pretty=True)) + elif arg == 'podcast': + # Load the podcast extension. It will automatically be loaded for all + # entries in the feed, too. Thus also for our “fe”. + fg.load_extension('podcast') + fg.podcast.itunes_author('Lars Kiesow') + fg.podcast.itunes_category('Technology', 'Podcasting') + fg.podcast.itunes_explicit('no') + fg.podcast.itunes_complete('no') + fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') + fg.podcast.itunes_owner('John Doe', 'john@example.com') + fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, consectetur ' + + 'adipiscing elit. Verba tu fingas et ea ' + + 'dicas, quae non sentias?') + fe.podcast.itunes_author('Lars Kiesow') + print_enc(fg.rss_str(pretty=True)) - elif arg.startswith('dc.'): - fg.load_extension('dc') - fg.dc.dc_contributor('Lars Kiesow') - if arg.endswith('.atom'): - print_enc (fg.atom_str(pretty=True)) - else: - print_enc (fg.rss_str(pretty=True)) + elif arg == 'torrent': + fg.load_extension('torrent') + fe.link(href='http://example.com/torrent/debian-8-netint.iso.torrent', + rel='alternate', + type='application/x-bittorrent, length=1000') + fe.torrent.filename('debian-8.4.0-i386-netint.iso.torrent') + fe.torrent.infohash('7661229811ef32014879ceedcdf4a48f256c88ba') + fe.torrent.contentlength('331350016') + fe.torrent.seeds('789') + fe.torrent.peers('456') + fe.torrent.verified('123') + print_enc(fg.rss_str(pretty=True)) - elif arg.startswith('syndication'): - fg.load_extension('syndication') - fg.syndication.update_period('daily') - fg.syndication.update_frequency(2) - fg.syndication.update_base('2000-01-01T12:00+00:00') - if arg.endswith('.rss'): - print_enc (fg.rss_str(pretty=True)) - else: - print_enc (fg.atom_str(pretty=True)) + elif arg.startswith('dc.'): + fg.load_extension('dc') + fg.dc.dc_contributor('Lars Kiesow') + if arg.endswith('.atom'): + print_enc(fg.atom_str(pretty=True)) + else: + print_enc(fg.rss_str(pretty=True)) - elif arg.endswith('atom'): - fg.atom_file(arg) + elif arg.startswith('syndication'): + fg.load_extension('syndication') + fg.syndication.update_period('daily') + fg.syndication.update_frequency(2) + fg.syndication.update_base('2000-01-01T12:00+00:00') + if arg.endswith('.rss'): + print_enc(fg.rss_str(pretty=True)) + else: + print_enc(fg.atom_str(pretty=True)) - elif arg.endswith('rss'): - fg.rss_file(arg) + elif arg.endswith('atom'): + fg.atom_file(arg) + + elif arg.endswith('rss'): + fg.rss_file(arg) diff --git a/feedgen/compat.py b/feedgen/compat.py index dc9127e..80b6cd6 100644 --- a/feedgen/compat.py +++ b/feedgen/compat.py @@ -2,6 +2,6 @@ import sys if sys.version_info[0] >= 3: - string_types = str + string_types = str else: - string_types = basestring + string_types = basestring # noqa: F821 diff --git a/feedgen/entry.py b/feedgen/entry.py index ef5dee3..aca2a9c 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- ''' - feedgen.entry - ~~~~~~~~~~~~~ + feedgen.entry + ~~~~~~~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' from lxml import etree @@ -17,666 +17,657 @@ from feedgen.compat import string_types class FeedEntry(object): - '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item - node. - ''' - - def __init__(self): - # ATOM - # required - self.__atom_id = None - self.__atom_title = None - self.__atom_updated = datetime.now(dateutil.tz.tzutc()) - - # recommended - self.__atom_author = None - self.__atom_content = None - self.__atom_link = None - self.__atom_summary = None - - # optional - self.__atom_category = None - self.__atom_contributor = None - self.__atom_published = None - self.__atom_source = None - self.__atom_rights = None - - # RSS - self.__rss_author = None - self.__rss_category = None - self.__rss_comments = None - self.__rss_description = None - self.__rss_content = None - self.__rss_enclosure = None - self.__rss_guid = None - self.__rss_link = None - self.__rss_pubDate = None - self.__rss_source = None - self.__rss_title = None - - # Extension list: - self.__extensions = {} - self.__extensions_register = {} - - - def atom_entry(self, extensions=True): - '''Create an ATOM entry and return it.''' - entry = etree.Element('entry') - if not ( self.__atom_id and self.__atom_title and self.__atom_updated ): - raise ValueError('Required fields not set') - id = etree.SubElement(entry, 'id') - id.text = self.__atom_id - title = etree.SubElement(entry, 'title') - title.text = self.__atom_title - updated = etree.SubElement(entry, 'updated') - updated.text = self.__atom_updated.isoformat() - - # An entry must contain an alternate link if there is no content element. - if not self.__atom_content: - if not True in [ l.get('rel') == 'alternate' \ - for l in self.__atom_link or [] ]: - raise ValueError('Entry must contain an alternate link or ' - + 'a content element.') - - # Add author elements - for a in self.__atom_author or []: - # Atom requires a name. Skip elements without. - if not a.get('name'): - continue - author = etree.SubElement(entry, 'author') - name = etree.SubElement(author, 'name') - name.text = a.get('name') - if a.get('email'): - email = etree.SubElement(author, 'email') - email.text = a.get('email') - if a.get('uri'): - uri = etree.SubElement(author, 'uri') - uri.text = a.get('uri') - - if self.__atom_content: - content = etree.SubElement(entry, 'content') - type = self.__atom_content.get('type') - if self.__atom_content.get('src'): - content.attrib['src'] = self.__atom_content['src'] - elif self.__atom_content.get('content'): - # Surround xhtml with a div tag, parse it and embed it - if type == 'xhtml': - content.append(etree.fromstring('''
%s
''' % \ - self.__atom_content.get('content'))) - elif type == 'CDATA': - content.text = etree.CDATA(self.__atom_content.get('content')) - # Emed the text in escaped form - elif not type or type.startswith('text') or type == 'html': - content.text = self.__atom_content.get('content') - # Parse XML and embed it - elif type.endswith('/xml') or type.endswith('+xml'): - content.append(etree.fromstring(self.__atom_content['content'])) - # Everything else should be included base64 encoded - else: - raise ValueError('base64 encoded content is not supported at the moment.' - + 'If you are interested , please file a bug report.') - # Add type description of the content - if type: - content.attrib['type'] = type - - for l in self.__atom_link or []: - link = etree.SubElement(entry, 'link', href=l['href']) - if l.get('rel'): - link.attrib['rel'] = l['rel'] - if l.get('type'): - link.attrib['type'] = l['type'] - if l.get('hreflang'): - link.attrib['hreflang'] = l['hreflang'] - if l.get('title'): - link.attrib['title'] = l['title'] - if l.get('length'): - link.attrib['length'] = l['length'] - - if self.__atom_summary: - summary = etree.SubElement(entry, 'summary') - summary.text = self.__atom_summary - - for c in self.__atom_category or []: - cat = etree.SubElement(entry, 'category', term=c['term']) - if c.get('scheme'): - cat.attrib['scheme'] = c['scheme'] - if c.get('label'): - cat.attrib['label'] = c['label'] - - # Add author elements - for c in self.__atom_contributor or []: - # Atom requires a name. Skip elements without. - if not c.get('name'): - continue - contrib = etree.SubElement(feed, 'contributor') - name = etree.SubElement(contrib, 'name') - name.text = c.get('name') - if c.get('email'): - email = etree.SubElement(contrib, 'email') - email.text = c.get('email') - if c.get('uri'): - uri = etree.SubElement(contrib, 'uri') - uri.text = c.get('uri') - - if self.__atom_published: - published = etree.SubElement(entry, 'published') - published.text = self.__atom_published.isoformat() - - if self.__atom_rights: - rights = etree.SubElement(feed, 'rights') - rights.text = self.__atom_rights - - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('atom'): - ext['inst'].extend_atom(entry) - - return entry - - - def rss_entry(self, extensions=True): - '''Create a RSS item and return it.''' - entry = etree.Element('item') - if not ( self.__rss_title or self.__rss_description or self.__rss_content): - raise ValueError('Required fields not set') - if self.__rss_title: - title = etree.SubElement(entry, 'title') - title.text = self.__rss_title - if self.__rss_link: - link = etree.SubElement(entry, 'link') - link.text = self.__rss_link - if self.__rss_description and self.__rss_content: - description = etree.SubElement(entry, 'description') - description.text = self.__rss_description - content = etree.SubElement(entry, '{%s}encoded' % - 'http://purl.org/rss/1.0/modules/content/') - content.text = etree.CDATA(self.__rss_content['content']) \ - if self.__rss_content.get('type', '') == 'CDATA' else self.__rss_content['content'] - elif self.__rss_description: - description = etree.SubElement(entry, 'description') - description.text = self.__rss_description - elif self.__rss_content: - description = etree.SubElement(entry, 'description') - description.text = self.__rss_content['content'] - for a in self.__rss_author or []: - author = etree.SubElement(entry, 'author') - author.text = a - if self.__rss_guid: - guid = etree.SubElement(entry, 'guid') - guid.text = self.__rss_guid - guid.attrib['isPermaLink'] = 'false' - for cat in self.__rss_category or []: - category = etree.SubElement(entry, 'category') - category.text = cat['value'] - if cat.get('domain'): - category.attrib['domain'] = cat['domain'] - if self.__rss_comments: - comments = etree.SubElement(entry, 'comments') - comments.text = self.__rss_comments - if self.__rss_enclosure: - enclosure = etree.SubElement(entry, 'enclosure') - enclosure.attrib['url'] = self.__rss_enclosure['url'] - enclosure.attrib['length'] = self.__rss_enclosure['length'] - enclosure.attrib['type'] = self.__rss_enclosure['type'] - if self.__rss_pubDate: - pubDate = etree.SubElement(entry, 'pubDate') - pubDate.text = formatRFC2822(self.__rss_pubDate) - - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('rss'): - ext['inst'].extend_rss(entry) - - return entry - - - - def title(self, title=None): - '''Get or set the title value of the entry. It should contain a human - readable title for the entry. Title is mandatory for both ATOM and RSS - and should not be blank. - - :param title: The new title of the entry. - :returns: The entriess title. - ''' - if not title is None: - self.__atom_title = title - self.__rss_title = title - return self.__atom_title - - - def id(self, id=None): - '''Get or set the entry id which identifies the entry using a universally - unique and permanent URI. Two entries in a feed can have the same value - for id if they represent the same entry at different points in time. This - method will also set rss:guid. Id is mandatory for an ATOM entry. - - :param id: New Id of the entry. - :returns: Id of the entry. - ''' - if not id is None: - self.__atom_id = id - self.__rss_guid = id - return self.__atom_id - - - def guid(self, guid=None): - '''Get or set the entries guid which is a string that uniquely identifies - the item. This will also set atom:id. - - :param guid: Id of the entry. - :returns: Id of the entry. - ''' - return self.id(guid) - - - def updated(self, updated=None): - '''Set or get the updated value which indicates the last time the entry - was modified in a significant way. - - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - - :param updated: The modification date. - :returns: Modification date as datetime.datetime - ''' - if not updated is None: - if isinstance(updated, string_types): - updated = dateutil.parser.parse(updated) - if not isinstance(updated, datetime): - raise ValueError('Invalid datetime format') - if updated.tzinfo is None: - raise ValueError('Datetime object has no timezone info') - self.__atom_updated = updated - self.__rss_lastBuildDate = updated - - return self.__atom_updated - - - def author(self, author=None, replace=False, **kwargs): - '''Get or set autor data. An author element is a dict containing a name, - an email adress and a uri. Name is mandatory for ATOM, email is mandatory - for RSS. - - This method can be called with: - - the fields of an author as keyword arguments - - the fields of an author as a dictionary - - a list of dictionaries containing the author fields - - An author has the following fields: - - *name* conveys a human-readable name for the person. - - *uri* contains a home page for the person. - - *email* contains an email address for the person. - - :param author: Dict or list of dicts with author data. - :param replace: Add or replace old data. - - Example:: - - >>> author( { 'name':'John Doe', 'email':'jdoe@example.com' } ) - [{'name':'John Doe','email':'jdoe@example.com'}] - - >>> author([{'name':'Mr. X'},{'name':'Max'}]) - [{'name':'John Doe','email':'jdoe@example.com'}, - {'name':'John Doe'}, {'name':'Max'}] - - >>> author( name='John Doe', email='jdoe@example.com', replace=True ) - [{'name':'John Doe','email':'jdoe@example.com'}] - - ''' - if author is None and kwargs: - author = kwargs - if not author is None: - if replace or self.__atom_author is None: - self.__atom_author = [] - self.__atom_author += ensure_format( author, - set(['name', 'email', 'uri']), set(['name'])) - self.__rss_author = [] - for a in self.__atom_author: - if a.get('email'): - self.__rss_author.append('%s (%s)' % ( a['email'], a['name'] )) - return self.__atom_author - - - def content(self, content=None, src=None, type=None): - '''Get or set the cntent of the entry which contains or links to the - complete content of the entry. Content must be provided for ATOM entries - if there is no alternate link, and should be provided if there is no - summary. If the content is set (not linked) it will also set - rss:description. - - :param content: The content of the feed entry. - :param src: Link to the entries content. - :param type: If type is CDATA content would not be escaped. - :returns: Content element of the entry. - ''' - if not src is None: - self.__atom_content = {'src':src} - elif not content is None: - self.__atom_content = {'content':content} - self.__rss_content = {'content':content} - if not type is None: - self.__atom_content['type'] = type - self.__rss_content['type'] = type - return self.__atom_content - - - def link(self, link=None, replace=False, **kwargs): - '''Get or set link data. An link element is a dict with the fields href, - rel, type, hreflang, title, and length. Href is mandatory for ATOM. - - This method can be called with: - - the fields of a link as keyword arguments - - the fields of a link as a dictionary - - a list of dictionaries containing the link fields - - A link has the following fields: - - - *href* is the URI of the referenced resource (typically a Web page) - - *rel* contains a single link relationship type. It can be a full URI, - or one of the following predefined values (default=alternate): - - - *alternate* an alternate representation of the entry or feed, for - example a permalink to the html version of the entry, or the front - page of the weblog. - - *enclosure* a related resource which is potentially large in size - and might require special handling, for example an audio or video - recording. - - *related* an document related to the entry or feed. - - *self* the feed itself. - - *via* the source of the information provided in the entry. - - - *type* indicates the media type of the resource. - - *hreflang* indicates the language of the referenced resource. - - *title* human readable information about the link, typically for - display purposes. - - *length* the length of the resource, in bytes. - - RSS only supports one link with nothing but a URL. So for the RSS link - element the last link with rel=alternate is used. - - RSS also supports one enclusure element per entry which is covered by the - link element in ATOM feed entries. So for the RSS enclusure element the - last link with rel=enclosure is used. - - :param link: Dict or list of dicts with data. - :param replace: Add or replace old data. - :returns: List of link data. - ''' - if link is None and kwargs: - link = kwargs - if not link is None: - if replace or self.__atom_link is None: - self.__atom_link = [] - self.__atom_link += ensure_format( link, - set(['href', 'rel', 'type', 'hreflang', 'title', 'length']), - set(['href']), - {'rel':['alternate', 'enclosure', 'related', 'self', 'via']}, - {'rel': 'alternate'} ) - # RSS only needs one URL. We use the first link for RSS: - for l in self.__atom_link: - if l.get('rel') == 'alternate': - self.__rss_link = l['href'] - elif l.get('rel') == 'enclosure': - self.__rss_enclosure = {'url':l['href']} - self.__rss_enclosure['type'] = l.get('type') - self.__rss_enclosure['length'] = l.get('length') or '0' - # return the set with more information (atom) - return self.__atom_link - - - def summary(self, summary=None): - '''Get or set the summary element of an entry which conveys a short - summary, abstract, or excerpt of the entry. Summary is an ATOM only - element and should be provided if there either is no content provided for - the entry, or that content is not inline (i.e., contains a src - attribute), or if the content is encoded in base64. - This method will also set the rss:description field if it wasn't - previously set or contains the old value of summary. - - :param summary: Summary of the entries contents. - :returns: Summary of the entries contents. - ''' - if not summary is None: - # Replace the RSS description with the summary if it was the summary - # before. Not if is the description. - if not self.__rss_description or \ - self.__rss_description == self.__atom_summary: - self.__rss_description = summary - self.__atom_summary = summary - return self.__atom_summary - - - def description(self, description=None, isSummary=False): - '''Get or set the description value which is the item synopsis. - Description is an RSS only element. For ATOM feeds it is split in summary - and content. The isSummary parameter can be used to control which ATOM - value is set when setting description. - - :param description: Description of the entry. - :param isSummary: If the description should be used as content or summary. - :returns: The entries description. - ''' - if not description is None: - self.__rss_description = description - if isSummary: - self.__atom_summary = description - else: - self.__atom_content = description - return self.__rss_description - - - def category(self, category=None, replace=False, **kwargs): - '''Get or set categories that the entry belongs to. - - This method can be called with: - - the fields of a category as keyword arguments - - the fields of a category as a dictionary - - a list of dictionaries containing the category fields - - A categories has the following fields: - - *term* identifies the category - - *scheme* identifies the categorization scheme via a URI. - - *label* provides a human-readable label for display - - If a label is present it is used for the RSS feeds. Otherwise the term is - used. The scheme is used for the domain attribute in RSS. - - :param category: Dict or list of dicts with data. - :param replace: Add or replace old data. - :returns: List of category data. - ''' - if category is None and kwargs: - category = kwargs - if not category is None: - if replace or self.__atom_category is None: - self.__atom_category = [] - self.__atom_category += ensure_format( - category, - set(['term', 'scheme', 'label']), - set(['term']) ) - # Map the ATOM categories to RSS categories. Use the atom:label as - # name or if not present the atom:term. The atom:scheme is the - # rss:domain. - self.__rss_category = [] - for cat in self.__atom_category: - rss_cat = {} - rss_cat['value'] = cat['label'] if cat.get('label') else cat['term'] - if cat.get('scheme'): - rss_cat['domain'] = cat['scheme'] - self.__rss_category.append( rss_cat ) - return self.__atom_category - - - def contributor(self, contributor=None, replace=False, **kwargs): - '''Get or set the contributor data of the feed. This is an ATOM only - value. - - This method can be called with: - - the fields of an contributor as keyword arguments - - the fields of an contributor as a dictionary - - a list of dictionaries containing the contributor fields - - An contributor has the following fields: - - *name* conveys a human-readable name for the person. - - *uri* contains a home page for the person. - - *email* contains an email address for the person. - - :param contributor: Dictionary or list of dictionaries with contributor data. - :param replace: Add or replace old data. - :returns: List of contributors as dictionaries. - ''' - if contributor is None and kwargs: - contributor = kwargs - if not contributor is None: - if replace or self.__atom_contributor is None: - self.__atom_contributor = [] - self.__atom_contributor += ensure_format( contributor, - set(['name', 'email', 'uri']), set(['name'])) - return self.__atom_contributor - - - def published(self, published=None): - '''Set or get the published value which contains the time of the initial - creation or first availability of the entry. - - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - - :param published: The creation date. - :returns: Creation date as datetime.datetime - ''' - if not published is None: - if isinstance(published, string_types): - published = dateutil.parser.parse(published) - if not isinstance(published, datetime): - raise ValueError('Invalid datetime format') - if published.tzinfo is None: - raise ValueError('Datetime object has no timezone info') - self.__atom_published = published - self.__rss_pubDate = published - - return self.__atom_published - - - def pubdate(self, pubDate=None): - '''Get or set the pubDate of the entry which indicates when the entry was - published. This method is just another name for the published(...) - method. - ''' - return self.published(pubDate) - - - def rights(self, rights=None): - '''Get or set the rights value of the entry which conveys information - about rights, e.g. copyrights, held in and over the entry. This ATOM value - will also set rss:copyright. - - :param rights: Rights information of the feed. - :returns: Rights information of the feed. - ''' - if not rights is None: - self.__atom_rights = rights - return self.__atom_rights - - - def comments(self, comments=None): - '''Get or set the the value of comments which is the url of the comments - page for the item. This is a RSS only value. - - :param comments: URL to the comments page. - :returns: URL to the comments page. - ''' - if not comments is None: - self.__rss_comments = comments - return self.__rss_comments - - - def enclosure(self, url=None, length=None, type=None): - '''Get or set the value of enclosure which describes a media object that - is attached to the item. This is a RSS only value which is represented by - link(rel=enclosure) in ATOM. ATOM feeds can furthermore contain several - enclosures while RSS may contain only one. That is why this method, if - repeatedly called, will add more than one enclosures to the feed. - However, only the last one is used for RSS. - - :param url: URL of the media object. - :param length: Size of the media in bytes. - :param type: Mimetype of the linked media. - :returns: Data of the enclosure element. - ''' - if not url is None: - self.link( href=url, rel='enclosure', type=type, length=length ) - return self.__rss_enclosure - - - def ttl(self, ttl=None): - '''Get or set the ttl value. It is an RSS only element. ttl stands for - time to live. It's a number of minutes that indicates how long a channel - can be cached before refreshing from the source. - - :param ttl: Integer value representing the time to live. - :returns: Time to live of of the entry. - ''' - if not ttl is None: - self.__rss_ttl = int(ttl) - return self.__rss_ttl - - - def load_extension(self, name, atom=True, rss=True): - '''Load a specific extension by name. - - :param name: Name of the extension to load. - :param atom: If the extension should be used for ATOM feeds. - :param rss: If the extension should be used for RSS feeds. - ''' - # Check loaded extensions - if not isinstance(self.__extensions, dict): - self.__extensions = {} - if name in self.__extensions.keys(): - raise ImportError('Extension already loaded') - - # Load extension - extname = name[0].upper() + name[1:] + 'EntryExtension' - try: - supmod = __import__('feedgen.ext.%s_entry' % name) - extmod = getattr(supmod.ext, name + '_entry') - except ImportError: - # Use FeedExtension module instead - supmod = __import__('feedgen.ext.%s' % name) - extmod = getattr(supmod.ext, name) - ext = getattr(extmod, extname) - self.register_extension(name, ext, atom, rss) - - - def register_extension(self, namespace, extension_class_entry=None, - atom=True, rss=True): - '''Register a specific extension by classes to a namespace. - - :param namespace: namespace for the extension - :param extension_class_entry: Class of the entry extension to load. - :param atom: If the extension should be used for ATOM feeds. - :param rss: If the extension should be used for RSS feeds. - ''' - # Check loaded extensions - # `load_extension` ignores the "Extension" suffix. - if not isinstance(self.__extensions, dict): - self.__extensions = {} - if namespace in self.__extensions.keys(): - raise ImportError('Extension already loaded') - if not extension_class_entry: - raise ImportError('No extension class') - - extinst = extension_class_entry() - setattr(self, namespace, extinst) - - # `load_extension` registry - self.__extensions[namespace] = { - 'inst':extinst, - 'extension_class_entry': extension_class_entry, - 'atom':atom, - 'rss':rss - } + '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item + node. + ''' + + def __init__(self): + # ATOM + # required + self.__atom_id = None + self.__atom_title = None + self.__atom_updated = datetime.now(dateutil.tz.tzutc()) + + # recommended + self.__atom_author = None + self.__atom_content = None + self.__atom_link = None + self.__atom_summary = None + + # optional + self.__atom_category = None + self.__atom_contributor = None + self.__atom_published = None + self.__atom_source = None + self.__atom_rights = None + + # RSS + self.__rss_author = None + self.__rss_category = None + self.__rss_comments = None + self.__rss_description = None + self.__rss_content = None + self.__rss_enclosure = None + self.__rss_guid = None + self.__rss_link = None + self.__rss_pubDate = None + self.__rss_source = None + self.__rss_title = None + + # Extension list: + self.__extensions = {} + self.__extensions_register = {} + + def atom_entry(self, extensions=True): + '''Create an ATOM entry and return it.''' + entry = etree.Element('entry') + if not (self.__atom_id and self.__atom_title and self.__atom_updated): + raise ValueError('Required fields not set') + id = etree.SubElement(entry, 'id') + id.text = self.__atom_id + title = etree.SubElement(entry, 'title') + title.text = self.__atom_title + updated = etree.SubElement(entry, 'updated') + updated.text = self.__atom_updated.isoformat() + + # An entry must contain an alternate link if there is no content + # element. + if not self.__atom_content: + links = self.__atom_link or [] + if not [l for l in links if l.get('rel') == 'alternate']: + raise ValueError('Entry must contain an alternate link or ' + + 'a content element.') + + # Add author elements + for a in self.__atom_author or []: + # Atom requires a name. Skip elements without. + if not a.get('name'): + continue + author = etree.SubElement(entry, 'author') + name = etree.SubElement(author, 'name') + name.text = a.get('name') + if a.get('email'): + email = etree.SubElement(author, 'email') + email.text = a.get('email') + if a.get('uri'): + uri = etree.SubElement(author, 'uri') + uri.text = a.get('uri') + + if self.__atom_content: + content = etree.SubElement(entry, 'content') + type = self.__atom_content.get('type') + if self.__atom_content.get('src'): + content.attrib['src'] = self.__atom_content['src'] + elif self.__atom_content.get('content'): + # Surround xhtml with a div tag, parse it and embed it + if type == 'xhtml': + content.append(etree.fromstring( + '
' + + self.__atom_content.get('content') + '
')) + elif type == 'CDATA': + content.text = etree.CDATA( + self.__atom_content.get('content')) + # Emed the text in escaped form + elif not type or type.startswith('text') or type == 'html': + content.text = self.__atom_content.get('content') + # Parse XML and embed it + elif type.endswith('/xml') or type.endswith('+xml'): + content.append(etree.fromstring( + self.__atom_content['content'])) + # Everything else should be included base64 encoded + else: + raise ValueError('base64 encoded content is not ' + + 'supported at the moment. Pull requests' + + ' adding support are welcome.') + # Add type description of the content + if type: + content.attrib['type'] = type + + for l in self.__atom_link or []: + link = etree.SubElement(entry, 'link', href=l['href']) + if l.get('rel'): + link.attrib['rel'] = l['rel'] + if l.get('type'): + link.attrib['type'] = l['type'] + if l.get('hreflang'): + link.attrib['hreflang'] = l['hreflang'] + if l.get('title'): + link.attrib['title'] = l['title'] + if l.get('length'): + link.attrib['length'] = l['length'] + + if self.__atom_summary: + summary = etree.SubElement(entry, 'summary') + summary.text = self.__atom_summary + + for c in self.__atom_category or []: + cat = etree.SubElement(entry, 'category', term=c['term']) + if c.get('scheme'): + cat.attrib['scheme'] = c['scheme'] + if c.get('label'): + cat.attrib['label'] = c['label'] + + # Add author elements + for c in self.__atom_contributor or []: + # Atom requires a name. Skip elements without. + if not c.get('name'): + continue + contrib = etree.SubElement(entry, 'contributor') + name = etree.SubElement(contrib, 'name') + name.text = c.get('name') + if c.get('email'): + email = etree.SubElement(contrib, 'email') + email.text = c.get('email') + if c.get('uri'): + uri = etree.SubElement(contrib, 'uri') + uri.text = c.get('uri') + + if self.__atom_published: + published = etree.SubElement(entry, 'published') + published.text = self.__atom_published.isoformat() + + if self.__atom_rights: + rights = etree.SubElement(entry, 'rights') + rights.text = self.__atom_rights + + if extensions: + for ext in self.__extensions.values() or []: + if ext.get('atom'): + ext['inst'].extend_atom(entry) + + return entry + + def rss_entry(self, extensions=True): + '''Create a RSS item and return it.''' + entry = etree.Element('item') + if not (self.__rss_title or + self.__rss_description or + self.__rss_content): + raise ValueError('Required fields not set') + if self.__rss_title: + title = etree.SubElement(entry, 'title') + title.text = self.__rss_title + if self.__rss_link: + link = etree.SubElement(entry, 'link') + link.text = self.__rss_link + if self.__rss_description and self.__rss_content: + description = etree.SubElement(entry, 'description') + description.text = self.__rss_description + XMLNS_CONTENT = 'http://purl.org/rss/1.0/modules/content/' + content = etree.SubElement(entry, '{%s}encoded' % XMLNS_CONTENT) + content.text = etree.CDATA(self.__rss_content['content']) \ + if self.__rss_content.get('type', '') == 'CDATA' \ + else self.__rss_content['content'] + elif self.__rss_description: + description = etree.SubElement(entry, 'description') + description.text = self.__rss_description + elif self.__rss_content: + description = etree.SubElement(entry, 'description') + description.text = self.__rss_content['content'] + for a in self.__rss_author or []: + author = etree.SubElement(entry, 'author') + author.text = a + if self.__rss_guid: + guid = etree.SubElement(entry, 'guid') + guid.text = self.__rss_guid + guid.attrib['isPermaLink'] = 'false' + for cat in self.__rss_category or []: + category = etree.SubElement(entry, 'category') + category.text = cat['value'] + if cat.get('domain'): + category.attrib['domain'] = cat['domain'] + if self.__rss_comments: + comments = etree.SubElement(entry, 'comments') + comments.text = self.__rss_comments + if self.__rss_enclosure: + enclosure = etree.SubElement(entry, 'enclosure') + enclosure.attrib['url'] = self.__rss_enclosure['url'] + enclosure.attrib['length'] = self.__rss_enclosure['length'] + enclosure.attrib['type'] = self.__rss_enclosure['type'] + if self.__rss_pubDate: + pubDate = etree.SubElement(entry, 'pubDate') + pubDate.text = formatRFC2822(self.__rss_pubDate) + + if extensions: + for ext in self.__extensions.values() or []: + if ext.get('rss'): + ext['inst'].extend_rss(entry) + + return entry + + def title(self, title=None): + '''Get or set the title value of the entry. It should contain a human + readable title for the entry. Title is mandatory for both ATOM and RSS + and should not be blank. + + :param title: The new title of the entry. + :returns: The entriess title. + ''' + if title is not None: + self.__atom_title = title + self.__rss_title = title + return self.__atom_title + + def id(self, id=None): + '''Get or set the entry id which identifies the entry using a + universally unique and permanent URI. Two entries in a feed can have + the same value for id if they represent the same entry at different + points in time. This method will also set rss:guid. Id is mandatory + for an ATOM entry. + + :param id: New Id of the entry. + :returns: Id of the entry. + ''' + if id is not None: + self.__atom_id = id + self.__rss_guid = id + return self.__atom_id + + def guid(self, guid=None): + '''Get or set the entries guid which is a string that uniquely + identifies the item. This will also set atom:id. + + :param guid: Id of the entry. + :returns: Id of the entry. + ''' + return self.id(guid) + + def updated(self, updated=None): + '''Set or get the updated value which indicates the last time the entry + was modified in a significant way. + + The value can either be a string which will automatically be parsed or + a datetime.datetime object. In any case it is necessary that the value + include timezone information. + + :param updated: The modification date. + :returns: Modification date as datetime.datetime + ''' + if updated is not None: + if isinstance(updated, string_types): + updated = dateutil.parser.parse(updated) + if not isinstance(updated, datetime): + raise ValueError('Invalid datetime format') + if updated.tzinfo is None: + raise ValueError('Datetime object has no timezone info') + self.__atom_updated = updated + self.__rss_lastBuildDate = updated + + return self.__atom_updated + + def author(self, author=None, replace=False, **kwargs): + '''Get or set autor data. An author element is a dict containing a + name, an email adress and a uri. Name is mandatory for ATOM, email is + mandatory for RSS. + + This method can be called with: + - the fields of an author as keyword arguments + - the fields of an author as a dictionary + - a list of dictionaries containing the author fields + + An author has the following fields: + - *name* conveys a human-readable name for the person. + - *uri* contains a home page for the person. + - *email* contains an email address for the person. + + :param author: Dict or list of dicts with author data. + :param replace: Add or replace old data. + + Example:: + + >>> author({'name':'John Doe', 'email':'jdoe@example.com'}) + [{'name':'John Doe','email':'jdoe@example.com'}] + + >>> author([{'name': 'Mr. X'}, {'name': 'Max'}]) + [{'name':'John Doe','email':'jdoe@example.com'}, + {'name':'John Doe'}, {'name':'Max'}] + + >>> author(name='John Doe', email='jdoe@example.com', replace=True) + [{'name':'John Doe','email':'jdoe@example.com'}] + + ''' + if author is None and kwargs: + author = kwargs + if author is not None: + if replace or self.__atom_author is None: + self.__atom_author = [] + self.__atom_author += ensure_format(author, + set(['name', 'email', 'uri']), + set(['name'])) + self.__rss_author = [] + for a in self.__atom_author: + if a.get('email'): + self.__rss_author.append('%(email)s (%(name)s)' % a) + return self.__atom_author + + def content(self, content=None, src=None, type=None): + '''Get or set the cntent of the entry which contains or links to the + complete content of the entry. Content must be provided for ATOM + entries if there is no alternate link, and should be provided if there + is no summary. If the content is set (not linked) it will also set + rss:description. + + :param content: The content of the feed entry. + :param src: Link to the entries content. + :param type: If type is CDATA content would not be escaped. + :returns: Content element of the entry. + ''' + if src is not None: + self.__atom_content = {'src': src} + elif content is not None: + self.__atom_content = {'content': content} + self.__rss_content = {'content': content} + if type is not None: + self.__atom_content['type'] = type + self.__rss_content['type'] = type + return self.__atom_content + + def link(self, link=None, replace=False, **kwargs): + '''Get or set link data. An link element is a dict with the fields + href, rel, type, hreflang, title, and length. Href is mandatory for + ATOM. + + This method can be called with: + - the fields of a link as keyword arguments + - the fields of a link as a dictionary + - a list of dictionaries containing the link fields + + A link has the following fields: + + - *href* is the URI of the referenced resource (typically a Web page) + - *rel* contains a single link relationship type. It can be a full URI, + or one of the following predefined values (default=alternate): + + - *alternate* an alternate representation of the entry or feed, for + example a permalink to the html version of the entry, or the + front page of the weblog. + - *enclosure* a related resource which is potentially large in size + and might require special handling, for example an audio or video + recording. + - *related* an document related to the entry or feed. + - *self* the feed itself. + - *via* the source of the information provided in the entry. + + - *type* indicates the media type of the resource. + - *hreflang* indicates the language of the referenced resource. + - *title* human readable information about the link, typically for + display purposes. + - *length* the length of the resource, in bytes. + + RSS only supports one link with nothing but a URL. So for the RSS link + element the last link with rel=alternate is used. + + RSS also supports one enclusure element per entry which is covered by + the link element in ATOM feed entries. So for the RSS enclusure element + the last link with rel=enclosure is used. + + :param link: Dict or list of dicts with data. + :param replace: Add or replace old data. + :returns: List of link data. + ''' + if link is None and kwargs: + link = kwargs + if link is not None: + if replace or self.__atom_link is None: + self.__atom_link = [] + self.__atom_link += ensure_format( + link, + set(['href', 'rel', 'type', 'hreflang', 'title', 'length']), + set(['href']), + {'rel': ['alternate', 'enclosure', 'related', 'self', 'via']}, + {'rel': 'alternate'}) + # RSS only needs one URL. We use the first link for RSS: + for l in self.__atom_link: + if l.get('rel') == 'alternate': + self.__rss_link = l['href'] + elif l.get('rel') == 'enclosure': + self.__rss_enclosure = {'url': l['href']} + self.__rss_enclosure['type'] = l.get('type') + self.__rss_enclosure['length'] = l.get('length') or '0' + # return the set with more information (atom) + return self.__atom_link + + def summary(self, summary=None): + '''Get or set the summary element of an entry which conveys a short + summary, abstract, or excerpt of the entry. Summary is an ATOM only + element and should be provided if there either is no content provided + for the entry, or that content is not inline (i.e., contains a src + attribute), or if the content is encoded in base64. This method will + also set the rss:description field if it wasn't previously set or + contains the old value of summary. + + :param summary: Summary of the entries contents. + :returns: Summary of the entries contents. + ''' + if summary is not None: + # Replace the RSS description with the summary if it was the + # summary before. Not if is the description. + if not self.__rss_description or \ + self.__rss_description == self.__atom_summary: + self.__rss_description = summary + self.__atom_summary = summary + return self.__atom_summary + + def description(self, description=None, isSummary=False): + '''Get or set the description value which is the item synopsis. + Description is an RSS only element. For ATOM feeds it is split in + summary and content. The isSummary parameter can be used to control + which ATOM value is set when setting description. + + :param description: Description of the entry. + :param isSummary: If the description should be used as content or + summary. + :returns: The entries description. + ''' + if description is not None: + self.__rss_description = description + if isSummary: + self.__atom_summary = description + else: + self.__atom_content = description + return self.__rss_description + + def category(self, category=None, replace=False, **kwargs): + '''Get or set categories that the entry belongs to. + + This method can be called with: + - the fields of a category as keyword arguments + - the fields of a category as a dictionary + - a list of dictionaries containing the category fields + + A categories has the following fields: + - *term* identifies the category + - *scheme* identifies the categorization scheme via a URI. + - *label* provides a human-readable label for display + + If a label is present it is used for the RSS feeds. Otherwise the term + is used. The scheme is used for the domain attribute in RSS. + + :param category: Dict or list of dicts with data. + :param replace: Add or replace old data. + :returns: List of category data. + ''' + if category is None and kwargs: + category = kwargs + if category is not None: + if replace or self.__atom_category is None: + self.__atom_category = [] + self.__atom_category += ensure_format( + category, + set(['term', 'scheme', 'label']), + set(['term'])) + # Map the ATOM categories to RSS categories. Use the atom:label as + # name or if not present the atom:term. The atom:scheme is the + # rss:domain. + self.__rss_category = [] + for cat in self.__atom_category: + rss_cat = {} + rss_cat['value'] = cat.get('label', cat['term']) + if cat.get('scheme'): + rss_cat['domain'] = cat['scheme'] + self.__rss_category.append(rss_cat) + return self.__atom_category + + def contributor(self, contributor=None, replace=False, **kwargs): + '''Get or set the contributor data of the feed. This is an ATOM only + value. + + This method can be called with: + - the fields of an contributor as keyword arguments + - the fields of an contributor as a dictionary + - a list of dictionaries containing the contributor fields + + An contributor has the following fields: + - *name* conveys a human-readable name for the person. + - *uri* contains a home page for the person. + - *email* contains an email address for the person. + + :param contributor: Dictionary or list of dictionaries with contributor + data. + :param replace: Add or replace old data. + :returns: List of contributors as dictionaries. + ''' + if contributor is None and kwargs: + contributor = kwargs + if contributor is not None: + if replace or self.__atom_contributor is None: + self.__atom_contributor = [] + self.__atom_contributor += ensure_format( + contributor, set(['name', 'email', 'uri']), set(['name'])) + return self.__atom_contributor + + def published(self, published=None): + '''Set or get the published value which contains the time of the initial + creation or first availability of the entry. + + The value can either be a string which will automatically be parsed or + a datetime.datetime object. In any case it is necessary that the value + include timezone information. + + :param published: The creation date. + :returns: Creation date as datetime.datetime + ''' + if published is not None: + if isinstance(published, string_types): + published = dateutil.parser.parse(published) + if not isinstance(published, datetime): + raise ValueError('Invalid datetime format') + if published.tzinfo is None: + raise ValueError('Datetime object has no timezone info') + self.__atom_published = published + self.__rss_pubDate = published + + return self.__atom_published + + def pubdate(self, pubDate=None): + '''Get or set the pubDate of the entry which indicates when the entry + was published. This method is just another name for the published(...) + method. + ''' + return self.published(pubDate) + + def rights(self, rights=None): + '''Get or set the rights value of the entry which conveys information + about rights, e.g. copyrights, held in and over the entry. This ATOM + value will also set rss:copyright. + + :param rights: Rights information of the feed. + :returns: Rights information of the feed. + ''' + if rights is not None: + self.__atom_rights = rights + return self.__atom_rights + + def comments(self, comments=None): + '''Get or set the the value of comments which is the url of the + comments page for the item. This is a RSS only value. + + :param comments: URL to the comments page. + :returns: URL to the comments page. + ''' + if comments is not None: + self.__rss_comments = comments + return self.__rss_comments + + def enclosure(self, url=None, length=None, type=None): + '''Get or set the value of enclosure which describes a media object + that is attached to the item. This is a RSS only value which is + represented by link(rel=enclosure) in ATOM. ATOM feeds can furthermore + contain several enclosures while RSS may contain only one. That is why + this method, if repeatedly called, will add more than one enclosures to + the feed. However, only the last one is used for RSS. + + :param url: URL of the media object. + :param length: Size of the media in bytes. + :param type: Mimetype of the linked media. + :returns: Data of the enclosure element. + ''' + if url is not None: + self.link(href=url, rel='enclosure', type=type, length=length) + return self.__rss_enclosure + + def ttl(self, ttl=None): + '''Get or set the ttl value. It is an RSS only element. ttl stands for + time to live. It's a number of minutes that indicates how long a + channel can be cached before refreshing from the source. + + :param ttl: Integer value representing the time to live. + :returns: Time to live of of the entry. + ''' + if ttl is not None: + self.__rss_ttl = int(ttl) + return self.__rss_ttl + + def load_extension(self, name, atom=True, rss=True): + '''Load a specific extension by name. + + :param name: Name of the extension to load. + :param atom: If the extension should be used for ATOM feeds. + :param rss: If the extension should be used for RSS feeds. + ''' + # Check loaded extensions + if not isinstance(self.__extensions, dict): + self.__extensions = {} + if name in self.__extensions.keys(): + raise ImportError('Extension already loaded') + + # Load extension + extname = name[0].upper() + name[1:] + 'EntryExtension' + try: + supmod = __import__('feedgen.ext.%s_entry' % name) + extmod = getattr(supmod.ext, name + '_entry') + except ImportError: + # Use FeedExtension module instead + supmod = __import__('feedgen.ext.%s' % name) + extmod = getattr(supmod.ext, name) + ext = getattr(extmod, extname) + self.register_extension(name, ext, atom, rss) + + def register_extension(self, namespace, extension_class_entry=None, + atom=True, rss=True): + '''Register a specific extension by classes to a namespace. + + :param namespace: namespace for the extension + :param extension_class_entry: Class of the entry extension to load. + :param atom: If the extension should be used for ATOM feeds. + :param rss: If the extension should be used for RSS feeds. + ''' + # Check loaded extensions + # `load_extension` ignores the "Extension" suffix. + if not isinstance(self.__extensions, dict): + self.__extensions = {} + if namespace in self.__extensions.keys(): + raise ImportError('Extension already loaded') + if not extension_class_entry: + raise ImportError('No extension class') + + extinst = extension_class_entry() + setattr(self, namespace, extinst) + + # `load_extension` registry + self.__extensions[namespace] = { + 'inst': extinst, + 'extension_class_entry': extension_class_entry, + 'atom': atom, + 'rss': rss + } diff --git a/feedgen/ext/__init__.py b/feedgen/ext/__init__.py index a3dd0f9..0e2b628 100644 --- a/feedgen/ext/__init__.py +++ b/feedgen/ext/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ - =========== - feedgen.ext - =========== + =========== + feedgen.ext + =========== """ diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py index da7f571..521139e 100644 --- a/feedgen/ext/base.py +++ b/feedgen/ext/base.py @@ -1,43 +1,43 @@ # -*- coding: utf-8 -*- ''' - feedgen.ext.base - ~~~~~~~~~~~~~~~~ + feedgen.ext.base + ~~~~~~~~~~~~~~~~ - Basic FeedGenerator extension which does nothing but provides all necessary - methods. + Basic FeedGenerator extension which does nothing but provides all necessary + methods. - :copyright: 2013, Lars Kiesow + :copyright: 2013, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' class BaseExtension(object): - '''Basic FeedGenerator extension. - ''' - def extend_ns(self): - '''Returns a dict that will be used in the namespace map for the feed.''' - return dict() + '''Basic FeedGenerator extension. + ''' + def extend_ns(self): + '''Returns a dict that will be used in the namespace map for the feed. + ''' + return dict() - def extend_rss(self, feed): - '''Extend a RSS feed xml structure containing all previously set fields. + def extend_rss(self, feed): + '''Extend a RSS feed xml structure containing all previously set fields. - :param feed: The feed xml root element. - :returns: The feed root element. - ''' - return feed + :param feed: The feed xml root element. + :returns: The feed root element. + ''' + return feed + def extend_atom(self, feed): + '''Extend an ATOM feed xml structure containing all previously set + fields. - def extend_atom(self, feed): - '''Extend an ATOM feed xml structure containing all previously set - fields. - - :param feed: The feed xml root element. - :returns: The feed root element. - ''' - return feed + :param feed: The feed xml root element. + :returns: The feed root element. + ''' + return feed class BaseEntryExtension(BaseExtension): - '''Basic FeedEntry extension. - ''' + '''Basic FeedEntry extension. + ''' diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index 69835d2..f52b506 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -1,419 +1,406 @@ # -*- coding: utf-8 -*- ''' - feedgen.ext.dc - ~~~~~~~~~~~~~~~~~~~ + feedgen.ext.dc + ~~~~~~~~~~~~~~~~~~~ - Extends the FeedGenerator to add Dubline Core Elements to the feeds. + Extends the FeedGenerator to add Dubline Core Elements to the feeds. - Descriptions partly taken from - http://dublincore.org/documents/dcmi-terms/#elements-coverage + Descriptions partly taken from + http://dublincore.org/documents/dcmi-terms/#elements-coverage - :copyright: 2013, Lars Kiesow + :copyright: 2013-2016, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' from lxml import etree -from feedgen.ext.base import BaseExtension, BaseEntryExtension +from feedgen.ext.base import BaseExtension class DcBaseExtension(BaseExtension): - '''Dublin Core Elements extension for podcasts. - ''' - - - def __init__(self): - # http://dublincore.org/documents/usageguide/elements.shtml - # http://dublincore.org/documents/dces/ - # http://dublincore.org/documents/dcmi-terms/ - self._dcelem_contributor = None - self._dcelem_coverage = None - self._dcelem_creator = None - self._dcelem_date = None - self._dcelem_description = None - self._dcelem_format = None - self._dcelem_identifier = None - self._dcelem_language = None - self._dcelem_publisher = None - self._dcelem_relation = None - self._dcelem_rights = None - self._dcelem_source = None - self._dcelem_subject = None - self._dcelem_title = None - self._dcelem_type = None - - def extend_ns(self): - return {'dc' : 'http://purl.org/dc/elements/1.1/'} - - def _extend_xml(self, xml_elem): - '''Extend xml_elem with set DC fields. - - :param xml_elem: etree element - ''' - DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/' - - for elem in ['contributor', 'coverage', 'creator', 'date', 'description', - 'language', 'publisher', 'relation', 'rights', 'source', 'subject', - 'title', 'type', 'format', 'identifier']: - if hasattr(self, '_dcelem_%s' % elem): - for val in getattr(self, '_dcelem_%s' % elem) or []: - node = etree.SubElement(xml_elem, '{%s}%s' % (DCELEMENTS_NS, elem)) - node.text = val - - - def extend_atom(self, atom_feed): - '''Extend an Atom feed with the set DC fields. - - :param atom_feed: The feed root element - :returns: The feed root element - ''' - - self._extend_xml(atom_feed) - - return atom_feed - - - - def extend_rss(self, rss_feed): - '''Extend a RSS feed with the set DC fields. - - :param rss_feed: The feed root element - :returns: The feed root element. - ''' - channel = rss_feed[0] - self._extend_xml(channel) - - return rss_feed - - - def dc_contributor(self, contributor=None, replace=False): - '''Get or set the dc:contributor which is an entity responsible for - making contributions to the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-contributor - - :param contributor: Contributor or list of contributors. - :param replace: Replace alredy set contributors (deault: False). - :returns: List of contributors. - ''' - if not contributor is None: - if not isinstance(contributor, list): - contributor = [contributor] - if replace or not self._dcelem_contributor: - self._dcelem_contributor = [] - self._dcelem_contributor += contributor - return self._dcelem_contributor - - - def dc_coverage(self, coverage=None, replace=True): - '''Get or set the dc:coverage which indicated the spatial or temporal - topic of the resource, the spatial applicability of the resource, or the - jurisdiction under which the resource is relevant. - - Spatial topic and spatial applicability may be a named place or a - location specified by its geographic coordinates. Temporal topic may be a - named period, date, or date range. A jurisdiction may be a named - administrative entity or a geographic place to which the resource - applies. Recommended best practice is to use a controlled vocabulary such - as the Thesaurus of Geographic Names [TGN]. Where appropriate, named - places or time periods can be used in preference to numeric identifiers - such as sets of coordinates or date ranges. - - References: [TGN] http://www.getty.edu/research/tools/vocabulary/tgn/index.html - - :param coverage: Coverage of the feed. - :param replace: Replace already set coverage (default: True). - :returns: Coverage of the feed. - ''' - if not coverage is None: - if not isinstance(coverage, list): - coverage = [coverage] - if replace or not self._dcelem_coverage: - self._dcelem_coverage = [] - self._dcelem_coverage = coverage - return self._dcelem_coverage - - - def dc_creator(self, creator=None, replace=False): - '''Get or set the dc:creator which is an entity primarily responsible for - making the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-creator - - :param creator: Creator or list of creators. - :param replace: Replace alredy set creators (deault: False). - :returns: List of creators. - ''' - if not creator is None: - if not isinstance(creator, list): - creator = [creator] - if replace or not self._dcelem_creator: - self._dcelem_creator = [] - self._dcelem_creator += creator - return self._dcelem_creator - - - def dc_date(self, date=None, replace=True): - '''Get or set the dc:date which describes a point or period of time - associated with an event in the lifecycle of the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-date - - :param date: Date or list of dates. - :param replace: Replace alredy set dates (deault: True). - :returns: List of dates. - ''' - if not date is None: - if not isinstance(date, list): - date = [date] - if replace or not self._dcelem_date: - self._dcelem_date = [] - self._dcelem_date += date - return self._dcelem_date - - - def dc_description(self, description=None, replace=True): - '''Get or set the dc:description which is an account of the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-description - - :param description: Description or list of descriptions. - :param replace: Replace alredy set descriptions (deault: True). - :returns: List of descriptions. - ''' - if not description is None: - if not isinstance(description, list): - description = [description] - if replace or not self._dcelem_description: - self._dcelem_description = [] - self._dcelem_description += description - return self._dcelem_description - - - def dc_format(self, format=None, replace=True): - '''Get or set the dc:format which describes the file format, physical - medium, or dimensions of the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-format - - :param format: Format of the resource or list of formats. - :param replace: Replace alredy set format (deault: True). - :returns: Format of the resource. - ''' - if not format is None: - if not isinstance(format, list): - format = [format] - if replace or not self._dcelem_format: - self._dcelem_format = [] - self._dcelem_format += format - return self._dcelem_format - - - def dc_identifier(self, identifier=None, replace=True): - '''Get or set the dc:identifier which should be an unambiguous reference - to the resource within a given context. - - For more inidentifierion see: - http://dublincore.org/documents/dcmi-terms/#elements-identifier - - :param identifier: Identifier of the resource or list of identifiers. - :param replace: Replace alredy set identifier (deault: True). - :returns: Identifiers of the resource. - ''' - if not identifier is None: - if not isinstance(identifier, list): - identifier = [identifier] - if replace or not self._dcelem_identifier: - self._dcelem_identifier = [] - self._dcelem_identifier += identifier - - - def dc_language(self, language=None, replace=True): - '''Get or set the dc:language which describes a language of the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-language - - :param language: Language or list of languages. - :param replace: Replace alredy set languages (deault: True). - :returns: List of languages. - ''' - if not language is None: - if not isinstance(language, list): - language = [language] - if replace or not self._dcelem_language: - self._dcelem_language = [] - self._dcelem_language += language - return self._dcelem_language - - - def dc_publisher(self, publisher=None, replace=False): - '''Get or set the dc:publisher which is an entity responsible for making - the resource available. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-publisher - - :param publisher: Publisher or list of publishers. - :param replace: Replace alredy set publishers (deault: False). - :returns: List of publishers. - ''' - if not publisher is None: - if not isinstance(publisher, list): - publisher = [publisher] - if replace or not self._dcelem_publisher: - self._dcelem_publisher = [] - self._dcelem_publisher += publisher - return self._dcelem_publisher - - - def dc_relation(self, relation=None, replace=False): - '''Get or set the dc:relation which describes a related ressource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-relation - - :param relation: Relation or list of relations. - :param replace: Replace alredy set relations (deault: False). - :returns: List of relations. - ''' - if not relation is None: - if not isinstance(relation, list): - relation = [relation] - if replace or not self._dcelem_relation: - self._dcelem_relation = [] - self._dcelem_relation += relation - return self._dcelem_relation - - - def dc_rights(self, rights=None, replace=False): - '''Get or set the dc:rights which may contain information about rights - held in and over the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-rights - - :param rights: Rights information or list of rights information. - :param replace: Replace alredy set rightss (deault: False). - :returns: List of rights information. - ''' - if not rights is None: - if not isinstance(rights, list): - rights = [rights] - if replace or not self._dcelem_rights: - self._dcelem_rights = [] - self._dcelem_rights += rights - return self._dcelem_rights - - - def dc_source(self, source=None, replace=False): - '''Get or set the dc:source which is a related resource from which the - described resource is derived. - - The described resource may be derived from the related resource in whole - or in part. Recommended best practice is to identify the related resource - by means of a string conforming to a formal identification system. - - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-source - - :param source: Source or list of sources. - :param replace: Replace alredy set sources (deault: False). - :returns: List of sources. - ''' - if not source is None: - if not isinstance(source, list): - source = [source] - if replace or not self._dcelem_source: - self._dcelem_source = [] - self._dcelem_source += source - return self._dcelem_source - - - def dc_subject(self, subject=None, replace=False): - '''Get or set the dc:subject which describes the topic of the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-subject - - :param subject: Subject or list of subjects. - :param replace: Replace alredy set subjects (deault: False). - :returns: List of subjects. - ''' - if not subject is None: - if not isinstance(subject, list): - subject = [subject] - if replace or not self._dcelem_subject: - self._dcelem_subject = [] - self._dcelem_subject += subject - return self._dcelem_subject - - - def dc_title(self, title=None, replace=True): - '''Get or set the dc:title which is a name given to the resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-title - - :param title: Title or list of titles. - :param replace: Replace alredy set titles (deault: False). - :returns: List of titles. - ''' - if not title is None: - if not isinstance(title, list): - title = [title] - if replace or not self._dcelem_title: - self._dcelem_title = [] - self._dcelem_title += title - return self._dcelem_title - - - def dc_type(self, type=None, replace=False): - '''Get or set the dc:type which describes the nature or genre of the - resource. - - For more information see: - http://dublincore.org/documents/dcmi-terms/#elements-type - - :param type: Type or list of types. - :param replace: Replace alredy set types (deault: False). - :returns: List of types. - ''' - if not type is None: - if not isinstance(type, list): - type = [type] - if replace or not self._dcelem_type: - self._dcelem_type = [] - self._dcelem_type += type - return self._dcelem_type + '''Dublin Core Elements extension for podcasts. + ''' + + def __init__(self): + # http://dublincore.org/documents/usageguide/elements.shtml + # http://dublincore.org/documents/dces/ + # http://dublincore.org/documents/dcmi-terms/ + self._dcelem_contributor = None + self._dcelem_coverage = None + self._dcelem_creator = None + self._dcelem_date = None + self._dcelem_description = None + self._dcelem_format = None + self._dcelem_identifier = None + self._dcelem_language = None + self._dcelem_publisher = None + self._dcelem_relation = None + self._dcelem_rights = None + self._dcelem_source = None + self._dcelem_subject = None + self._dcelem_title = None + self._dcelem_type = None + + def extend_ns(self): + return {'dc': 'http://purl.org/dc/elements/1.1/'} + + def _extend_xml(self, xml_elem): + '''Extend xml_elem with set DC fields. + + :param xml_elem: etree element + ''' + DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/' + + for elem in ['contributor', 'coverage', 'creator', 'date', + 'description', 'language', 'publisher', 'relation', + 'rights', 'source', 'subject', 'title', 'type', 'format', + 'identifier']: + if hasattr(self, '_dcelem_%s' % elem): + for val in getattr(self, '_dcelem_%s' % elem) or []: + node = etree.SubElement(xml_elem, + '{%s}%s' % (DCELEMENTS_NS, elem)) + node.text = val + + def extend_atom(self, atom_feed): + '''Extend an Atom feed with the set DC fields. + + :param atom_feed: The feed root element + :returns: The feed root element + ''' + + self._extend_xml(atom_feed) + + return atom_feed + + def extend_rss(self, rss_feed): + '''Extend a RSS feed with the set DC fields. + + :param rss_feed: The feed root element + :returns: The feed root element. + ''' + channel = rss_feed[0] + self._extend_xml(channel) + + return rss_feed + + def dc_contributor(self, contributor=None, replace=False): + '''Get or set the dc:contributor which is an entity responsible for + making contributions to the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-contributor + + :param contributor: Contributor or list of contributors. + :param replace: Replace alredy set contributors (deault: False). + :returns: List of contributors. + ''' + if contributor is not None: + if not isinstance(contributor, list): + contributor = [contributor] + if replace or not self._dcelem_contributor: + self._dcelem_contributor = [] + self._dcelem_contributor += contributor + return self._dcelem_contributor + + def dc_coverage(self, coverage=None, replace=True): + '''Get or set the dc:coverage which indicated the spatial or temporal + topic of the resource, the spatial applicability of the resource, or + the jurisdiction under which the resource is relevant. + + Spatial topic and spatial applicability may be a named place or a + location specified by its geographic coordinates. Temporal topic may be + a named period, date, or date range. A jurisdiction may be a named + administrative entity or a geographic place to which the resource + applies. Recommended best practice is to use a controlled vocabulary + such as the Thesaurus of Geographic Names [TGN]. Where appropriate, + named places or time periods can be used in preference to numeric + identifiers such as sets of coordinates or date ranges. + + References: + [TGN] http://www.getty.edu/research/tools/vocabulary/tgn/index.html + + :param coverage: Coverage of the feed. + :param replace: Replace already set coverage (default: True). + :returns: Coverage of the feed. + ''' + if coverage is not None: + if not isinstance(coverage, list): + coverage = [coverage] + if replace or not self._dcelem_coverage: + self._dcelem_coverage = [] + self._dcelem_coverage = coverage + return self._dcelem_coverage + + def dc_creator(self, creator=None, replace=False): + '''Get or set the dc:creator which is an entity primarily responsible + for making the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-creator + + :param creator: Creator or list of creators. + :param replace: Replace alredy set creators (deault: False). + :returns: List of creators. + ''' + if creator is not None: + if not isinstance(creator, list): + creator = [creator] + if replace or not self._dcelem_creator: + self._dcelem_creator = [] + self._dcelem_creator += creator + return self._dcelem_creator + + def dc_date(self, date=None, replace=True): + '''Get or set the dc:date which describes a point or period of time + associated with an event in the lifecycle of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-date + + :param date: Date or list of dates. + :param replace: Replace alredy set dates (deault: True). + :returns: List of dates. + ''' + if date is not None: + if not isinstance(date, list): + date = [date] + if replace or not self._dcelem_date: + self._dcelem_date = [] + self._dcelem_date += date + return self._dcelem_date + + def dc_description(self, description=None, replace=True): + '''Get or set the dc:description which is an account of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-description + + :param description: Description or list of descriptions. + :param replace: Replace alredy set descriptions (deault: True). + :returns: List of descriptions. + ''' + if description is not None: + if not isinstance(description, list): + description = [description] + if replace or not self._dcelem_description: + self._dcelem_description = [] + self._dcelem_description += description + return self._dcelem_description + + def dc_format(self, format=None, replace=True): + '''Get or set the dc:format which describes the file format, physical + medium, or dimensions of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-format + + :param format: Format of the resource or list of formats. + :param replace: Replace alredy set format (deault: True). + :returns: Format of the resource. + ''' + if format is not None: + if not isinstance(format, list): + format = [format] + if replace or not self._dcelem_format: + self._dcelem_format = [] + self._dcelem_format += format + return self._dcelem_format + + def dc_identifier(self, identifier=None, replace=True): + '''Get or set the dc:identifier which should be an unambiguous + reference to the resource within a given context. + + For more inidentifierion see: + http://dublincore.org/documents/dcmi-terms/#elements-identifier + + :param identifier: Identifier of the resource or list of identifiers. + :param replace: Replace alredy set identifier (deault: True). + :returns: Identifiers of the resource. + ''' + if identifier is not None: + if not isinstance(identifier, list): + identifier = [identifier] + if replace or not self._dcelem_identifier: + self._dcelem_identifier = [] + self._dcelem_identifier += identifier + + def dc_language(self, language=None, replace=True): + '''Get or set the dc:language which describes a language of the + resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-language + + :param language: Language or list of languages. + :param replace: Replace alredy set languages (deault: True). + :returns: List of languages. + ''' + if language is not None: + if not isinstance(language, list): + language = [language] + if replace or not self._dcelem_language: + self._dcelem_language = [] + self._dcelem_language += language + return self._dcelem_language + + def dc_publisher(self, publisher=None, replace=False): + '''Get or set the dc:publisher which is an entity responsible for + making the resource available. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-publisher + + :param publisher: Publisher or list of publishers. + :param replace: Replace alredy set publishers (deault: False). + :returns: List of publishers. + ''' + if publisher is not None: + if not isinstance(publisher, list): + publisher = [publisher] + if replace or not self._dcelem_publisher: + self._dcelem_publisher = [] + self._dcelem_publisher += publisher + return self._dcelem_publisher + + def dc_relation(self, relation=None, replace=False): + '''Get or set the dc:relation which describes a related ressource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-relation + + :param relation: Relation or list of relations. + :param replace: Replace alredy set relations (deault: False). + :returns: List of relations. + ''' + if relation is not None: + if not isinstance(relation, list): + relation = [relation] + if replace or not self._dcelem_relation: + self._dcelem_relation = [] + self._dcelem_relation += relation + return self._dcelem_relation + + def dc_rights(self, rights=None, replace=False): + '''Get or set the dc:rights which may contain information about rights + held in and over the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-rights + + :param rights: Rights information or list of rights information. + :param replace: Replace alredy set rightss (deault: False). + :returns: List of rights information. + ''' + if rights is not None: + if not isinstance(rights, list): + rights = [rights] + if replace or not self._dcelem_rights: + self._dcelem_rights = [] + self._dcelem_rights += rights + return self._dcelem_rights + + def dc_source(self, source=None, replace=False): + '''Get or set the dc:source which is a related resource from which the + described resource is derived. + + The described resource may be derived from the related resource in + whole or in part. Recommended best practice is to identify the related + resource by means of a string conforming to a formal identification + system. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-source + + :param source: Source or list of sources. + :param replace: Replace alredy set sources (deault: False). + :returns: List of sources. + ''' + if source is not None: + if not isinstance(source, list): + source = [source] + if replace or not self._dcelem_source: + self._dcelem_source = [] + self._dcelem_source += source + return self._dcelem_source + + def dc_subject(self, subject=None, replace=False): + '''Get or set the dc:subject which describes the topic of the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-subject + + :param subject: Subject or list of subjects. + :param replace: Replace alredy set subjects (deault: False). + :returns: List of subjects. + ''' + if subject is not None: + if not isinstance(subject, list): + subject = [subject] + if replace or not self._dcelem_subject: + self._dcelem_subject = [] + self._dcelem_subject += subject + return self._dcelem_subject + + def dc_title(self, title=None, replace=True): + '''Get or set the dc:title which is a name given to the resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-title + + :param title: Title or list of titles. + :param replace: Replace alredy set titles (deault: False). + :returns: List of titles. + ''' + if title is not None: + if not isinstance(title, list): + title = [title] + if replace or not self._dcelem_title: + self._dcelem_title = [] + self._dcelem_title += title + return self._dcelem_title + + def dc_type(self, type=None, replace=False): + '''Get or set the dc:type which describes the nature or genre of the + resource. + + For more information see: + http://dublincore.org/documents/dcmi-terms/#elements-type + + :param type: Type or list of types. + :param replace: Replace alredy set types (deault: False). + :returns: List of types. + ''' + if type is not None: + if not isinstance(type, list): + type = [type] + if replace or not self._dcelem_type: + self._dcelem_type = [] + self._dcelem_type += type + return self._dcelem_type + class DcExtension(DcBaseExtension): - '''Dublin Core Elements extension for podcasts. - ''' + '''Dublin Core Elements extension for podcasts. + ''' + class DcEntryExtension(DcBaseExtension): - '''Dublin Core Elements extension for podcasts. - ''' - def extend_atom(self, entry): - '''Add dc elements to an atom item. Alters the item itself. + '''Dublin Core Elements extension for podcasts. + ''' + def extend_atom(self, entry): + '''Add dc elements to an atom item. Alters the item itself. - :param entry: An atom entry element. - :returns: The entry element. - ''' - self._extend_xml(entry) - return entry + :param entry: An atom entry element. + :returns: The entry element. + ''' + self._extend_xml(entry) + return entry - def extend_rss(self, item): - '''Add dc elements to a RSS item. Alters the item itself. + def extend_rss(self, item): + '''Add dc elements to a RSS item. Alters the item itself. - :param item: A RSS item element. - :returns: The item element. - ''' - self._extend_xml(item) - return item + :param item: A RSS item element. + :returns: The item element. + ''' + self._extend_xml(item) + return item diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 44d5b39..e2bb3e5 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- ''' - feedgen.ext.podcast - ~~~~~~~~~~~~~~~~~~~ + feedgen.ext.podcast + ~~~~~~~~~~~~~~~~~~~ - Extends the FeedGenerator to produce podcasts. + Extends the FeedGenerator to produce podcasts. - :copyright: 2013, Lars Kiesow + :copyright: 2013, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' from lxml import etree @@ -17,343 +17,343 @@ from feedgen.compat import string_types class PodcastExtension(BaseExtension): - '''FeedGenerator extension for podcasts. - ''' - - - def __init__(self): - ## ITunes tags - # http://www.apple.com/itunes/podcasts/specs.html#rss - self.__itunes_author = None - self.__itunes_block = None - self.__itunes_category = None - self.__itunes_image = None - self.__itunes_explicit = None - self.__itunes_complete = None - self.__itunes_new_feed_url = None - self.__itunes_owner = None - self.__itunes_subtitle = None - self.__itunes_summary = None - - - def extend_ns(self): - return {'itunes' : 'http://www.itunes.com/dtds/podcast-1.0.dtd'} - - - def extend_rss(self, rss_feed): - '''Extend an RSS feed root with set itunes fields. - - :returns: The feed root element. - ''' - ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' - channel = rss_feed[0] - - if self.__itunes_author: - author = etree.SubElement(channel, '{%s}author' % ITUNES_NS) - author.text = self.__itunes_author - - if not self.__itunes_block is None: - block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) - block.text = 'yes' if self.__itunes_block else 'no' - - for c in self.__itunes_category or []: - if not c.get('cat'): - continue - category = channel.find('{%s}category[@text="%s"]' % (ITUNES_NS,c.get('cat'))) - if category == None: - category = etree.SubElement(channel, '{%s}category' % ITUNES_NS) - category.attrib['text'] = c.get('cat') - - if c.get('sub'): - subcategory = etree.SubElement(category, '{%s}category' % ITUNES_NS) - subcategory.attrib['text'] = c.get('sub') - - if self.__itunes_image: - image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) - image.attrib['href'] = self.__itunes_image - - if self.__itunes_explicit in ('yes', 'no', 'clean'): - explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) - explicit.text = self.__itunes_explicit - - if self.__itunes_complete in ('yes', 'no'): - complete = etree.SubElement(channel, '{%s}complete' % ITUNES_NS) - complete.text = self.__itunes_complete - - if self.__itunes_new_feed_url: - new_feed_url = etree.SubElement(channel, '{%s}new-feed-url' % ITUNES_NS) - new_feed_url.text = self.__itunes_new_feed_url - - if self.__itunes_owner: - owner = etree.SubElement(channel, '{%s}owner' % ITUNES_NS) - owner_name = etree.SubElement(owner, '{%s}name' % ITUNES_NS) - owner_name.text = self.__itunes_owner.get('name') - owner_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) - owner_email.text = self.__itunes_owner.get('email') - - if self.__itunes_subtitle: - subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) - subtitle.text = self.__itunes_subtitle - - if self.__itunes_summary: - summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) - summary.text = self.__itunes_summary - - return rss_feed - - - def itunes_author(self, itunes_author=None): - '''Get or set the itunes:author. The content of this tag is shown in the - Artist column in iTunes. If the tag is not present, iTunes uses the - contents of the tag. If is not present at the - feed level, iTunes will use the contents of . - - :param itunes_author: The author of the podcast. - :returns: The author of the podcast. - ''' - if not itunes_author is None: - self.__itunes_author = itunes_author - return self.__itunes_author - - - def itunes_block(self, itunes_block=None): - '''Get or set the ITunes block attribute. Use this to prevent the entire - podcast from appearing in the iTunes podcast directory. - - :param itunes_block: Block the podcast. - :returns: If the podcast is blocked. - ''' - if not itunes_block is None: - self.__itunes_block = itunes_block - return self.__itunes_block - - def itunes_category(self, itunes_category=None, replace=False, **kwargs): - '''Get or set the ITunes category which appears in the category column - and in iTunes Store Browser. - - The (sub-)category has to be one from the values defined at - http://www.apple.com/itunes/podcasts/specs.html#categories - - This method can be called with: - - - the fields of an itunes_category as keyword arguments - - the fields of an itunes_category as a dictionary - - a list of dictionaries containing the itunes_category fields - - An itunes_category has the following fields: - - - *cat* name for a category. - - *sub* name for a subcategory, child of category - - If a podcast has more than one subcategory from the same category, the - category is called more than once. - - Likei the parameter:: - - [{"cat":"Arts","sub":"Design"},{"cat":"Arts","sub":"Food"}] - - …would become:: - - - - - - - - :param itunes_category: Dictionary or list of dictionaries with - itunes_category data. - :param replace: Add or replace old data. - :returns: List of itunes_categories as dictionaries. - - --- - - **Important note about deprecated parameter syntax:** Old version of the - feedgen did only support one category plus one subcategory which would be - passed to this ducntion as first two parameters. For compatibility - reasons, this still works but should not be used any may be removed at - any time. - ''' - # Ensure old API still works for now. Note that the API is deprecated and - # this fallback may be removed at any time. - if isinstance(itunes_category, string_types): - itunes_category = {'cat':itunes_category} - if replace: - itunes_category['sub'] = replace - replace=True - if itunes_category is None and kwargs: - itunes_category = kwargs - if not itunes_category is None: - if replace or self.__itunes_category is None: - self.__itunes_category = [] - self.__itunes_category += ensure_format( itunes_category, - set(['cat', 'sub']), set(['cat'])) - return self.__itunes_category - - - def itunes_image(self, itunes_image=None): - '''Get or set the image for the podcast. This tag specifies the artwork - for your podcast. Put the URL to the image in the href attribute. iTunes - prefers square .jpg images that are at least 1400x1400 pixels, which is - different from what is specified for the standard RSS image tag. In order - for a podcast to be eligible for an iTunes Store feature, the - accompanying image must be at least 1400x1400 pixels. - - iTunes supports images in JPEG and PNG formats with an RGB color space - (CMYK is not supported). The URL must end in ".jpg" or ".png". If the - tag is not present, iTunes will use the contents of the - RSS image tag. - - If you change your podcast’s image, also change the file’s name. iTunes - may not change the image if it checks your feed and the image URL is the - same. The server hosting your cover art image must allow HTTP head - requests for iTS to be able to automatically update your cover art. - - :param itunes_image: Image of the podcast. - :returns: Image of the podcast. - ''' - if not itunes_image is None: - if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): - ValueError('Image file must be png or jpg') - self.__itunes_image = itunes_image - return self.__itunes_image - - - def itunes_explicit(self, itunes_explicit=None): - '''Get or the the itunes:explicit value of the podcast. This tag should - be used to indicate whether your podcast contains explicit material. The - three values for this tag are "yes", "no", and "clean". - - If you populate this tag with "yes", an "explicit" parental advisory - graphic will appear next to your podcast artwork on the iTunes Store and - in the Name column in iTunes. If the value is "clean", the parental - advisory type is considered Clean, meaning that no explicit language or - adult content is included anywhere in the episodes, and a "clean" graphic - will appear. If the explicit tag is present and has any other value - (e.g., "no"), you see no indicator — blank is the default advisory type. - - :param itunes_explicit: If the podcast contains explicit material. - :returns: If the podcast contains explicit material. - ''' - if not itunes_explicit is None: - if not itunes_explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value for explicit tag') - self.__itunes_explicit = itunes_explicit - return self.__itunes_explicit - - - def itunes_complete(self, itunes_complete=None): - '''Get or set the itunes:complete value of the podcast. This tag can be - used to indicate the completion of a podcast. - - If you populate this tag with "yes", you are indicating that no more - episodes will be added to the podcast. If the tag is - present and has any other value (e.g. “no”), it will have no effect on - the podcast. - - :param itunes_complete: If the podcast is complete. - :returns: If the podcast is complete. - ''' - if not itunes_complete is None: - if not itunes_complete in ('yes', 'no', '', True, False): - raise ValueError('Invalid value for complete tag') - if itunes_complete == True: - itunes_complete = 'yes' - if itunes_complete == False: - itunes_complete = 'no' - self.__itunes_complete = itunes_complete - return self.__itunes_complete - - - def itunes_new_feed_url(self, itunes_new_feed_url=None): - '''Get or set the new-feed-url property of the podcast. This tag allows - you to change the URL where the podcast feed is located - - After adding the tag to your old feed, you should maintain the old feed - for 48 hours before retiring it. At that point, iTunes will have updated - the directory with the new feed URL. - - :param itunes_new_feed_url: New feed URL. - :returns: New feed URL. - ''' - if not itunes_new_feed_url is None: - self.__itunes_new_feed_url = itunes_new_feed_url - return self.__itunes_new_feed_url - - - def itunes_owner(self, name=None, email=None): - '''Get or set the itunes:owner of the podcast. This tag contains - information that will be used to contact the owner of the podcast for - communication specifically about the podcast. It will not be publicly - displayed. - - :param itunes_owner: The owner of the feed. - :returns: Data of the owner of the feed. - ''' - if not name is None: - if name and email: - self.__itunes_owner = {'name':name, 'email':email} - elif not name and not email: - self.__itunes_owner = None - else: - raise ValueError('Both name and email have to be set.') - return self.__itunes_owner - - - def itunes_subtitle(self, itunes_subtitle=None): - '''Get or set the itunes:subtitle value for the podcast. The contents of - this tag are shown in the Description column in iTunes. The subtitle - displays best if it is only a few words long. - - :param itunes_subtitle: Subtitle of the podcast. - :returns: Subtitle of the podcast. - ''' - if not itunes_subtitle is None: - self.__itunes_subtitle = itunes_subtitle - return self.__itunes_subtitle - - - def itunes_summary(self, itunes_summary=None): - '''Get or set the itunes:summary value for the podcast. The contents of - this tag are shown in a separate window that appears when the "circled i" - in the Description column is clicked. It also appears on the iTunes page - for your podcast. This field can be up to 4000 characters. If - is not included, the contents of the tag - are used. - - :param itunes_summary: Summary of the podcast. - :returns: Summary of the podcast. - ''' - if not itunes_summary is None: - self.__itunes_summary = itunes_summary - return self.__itunes_summary - - - _itunes_categories = { - 'Arts': [ 'Design', 'Fashion & Beauty', 'Food', 'Literature', - 'Performing Arts', 'Visual Arts' ], - 'Business' : [ 'Business News', 'Careers', 'Investing', - 'Management & Marketing', 'Shopping' ], - 'Comedy' : [], - 'Education' : [ 'Education', 'Education Technology', - 'Higher Education', 'K-12', 'Language Courses', 'Training' ], - 'Games & Hobbies' : [ 'Automotive', 'Aviation', 'Hobbies', - 'Other Games', 'Video Games' ], - 'Government & Organizations' : [ 'Local', 'National', 'Non-Profit', - 'Regional' ], - 'Health' : [ 'Alternative Health', 'Fitness & Nutrition', 'Self-Help', - 'Sexuality' ], - 'Kids & Family' : [], - 'Music' : [], - 'News & Politics' : [], - 'Religion & Spirituality' : [ 'Buddhism', 'Christianity', 'Hinduism', - 'Islam', 'Judaism', 'Other', 'Spirituality' ], - 'Science & Medicine' : [ 'Medicine', 'Natural Sciences', - 'Social Sciences' ], - 'Society & Culture' : [ 'History', 'Personal Journals', 'Philosophy', - 'Places & Travel' ], - 'Sports & Recreation' : [ 'Amateur', 'College & High School', - 'Outdoor', 'Professional' ], - 'Technology' : [ 'Gadgets', 'Tech News', 'Podcasting', - 'Software How-To' ], - 'TV & Film' : [] - } + '''FeedGenerator extension for podcasts. + ''' + + def __init__(self): + # ITunes tags + # http://www.apple.com/itunes/podcasts/specs.html#rss + self.__itunes_author = None + self.__itunes_block = None + self.__itunes_category = None + self.__itunes_image = None + self.__itunes_explicit = None + self.__itunes_complete = None + self.__itunes_new_feed_url = None + self.__itunes_owner = None + self.__itunes_subtitle = None + self.__itunes_summary = None + + def extend_ns(self): + return {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + + def extend_rss(self, rss_feed): + '''Extend an RSS feed root with set itunes fields. + + :returns: The feed root element. + ''' + ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' + channel = rss_feed[0] + + if self.__itunes_author: + author = etree.SubElement(channel, '{%s}author' % ITUNES_NS) + author.text = self.__itunes_author + + if self.__itunes_block is not None: + block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) + block.text = 'yes' if self.__itunes_block else 'no' + + for c in self.__itunes_category or []: + if not c.get('cat'): + continue + category = channel.find( + '{%s}category[@text="%s"]' % (ITUNES_NS, c.get('cat'))) + if category is None: + category = etree.SubElement(channel, + '{%s}category' % ITUNES_NS) + category.attrib['text'] = c.get('cat') + + if c.get('sub'): + subcategory = etree.SubElement(category, + '{%s}category' % ITUNES_NS) + subcategory.attrib['text'] = c.get('sub') + + if self.__itunes_image: + image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) + image.attrib['href'] = self.__itunes_image + + if self.__itunes_explicit in ('yes', 'no', 'clean'): + explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) + explicit.text = self.__itunes_explicit + + if self.__itunes_complete in ('yes', 'no'): + complete = etree.SubElement(channel, '{%s}complete' % ITUNES_NS) + complete.text = self.__itunes_complete + + if self.__itunes_new_feed_url: + new_feed_url = etree.SubElement(channel, + '{%s}new-feed-url' % ITUNES_NS) + new_feed_url.text = self.__itunes_new_feed_url + + if self.__itunes_owner: + owner = etree.SubElement(channel, '{%s}owner' % ITUNES_NS) + owner_name = etree.SubElement(owner, '{%s}name' % ITUNES_NS) + owner_name.text = self.__itunes_owner.get('name') + owner_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) + owner_email.text = self.__itunes_owner.get('email') + + if self.__itunes_subtitle: + subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) + subtitle.text = self.__itunes_subtitle + + if self.__itunes_summary: + summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) + summary.text = self.__itunes_summary + + return rss_feed + + def itunes_author(self, itunes_author=None): + '''Get or set the itunes:author. The content of this tag is shown in + the Artist column in iTunes. If the tag is not present, iTunes uses the + contents of the tag. If is not present at the + feed level, iTunes will use the contents of . + + :param itunes_author: The author of the podcast. + :returns: The author of the podcast. + ''' + if itunes_author is not None: + self.__itunes_author = itunes_author + return self.__itunes_author + + def itunes_block(self, itunes_block=None): + '''Get or set the ITunes block attribute. Use this to prevent the + entire podcast from appearing in the iTunes podcast directory. + + :param itunes_block: Block the podcast. + :returns: If the podcast is blocked. + ''' + if itunes_block is not None: + self.__itunes_block = itunes_block + return self.__itunes_block + + def itunes_category(self, itunes_category=None, replace=False, **kwargs): + '''Get or set the ITunes category which appears in the category column + and in iTunes Store Browser. + + The (sub-)category has to be one from the values defined at + http://www.apple.com/itunes/podcasts/specs.html#categories + + This method can be called with: + + - the fields of an itunes_category as keyword arguments + - the fields of an itunes_category as a dictionary + - a list of dictionaries containing the itunes_category fields + + An itunes_category has the following fields: + + - *cat* name for a category. + - *sub* name for a subcategory, child of category + + If a podcast has more than one subcategory from the same category, the + category is called more than once. + + Likei the parameter:: + + [{"cat":"Arts","sub":"Design"},{"cat":"Arts","sub":"Food"}] + + …would become:: + + + + + + + + :param itunes_category: Dictionary or list of dictionaries with + itunes_category data. + :param replace: Add or replace old data. + :returns: List of itunes_categories as dictionaries. + + --- + + **Important note about deprecated parameter syntax:** Old version of + the feedgen did only support one category plus one subcategory which + would be passed to this ducntion as first two parameters. For + compatibility reasons, this still works but should not be used any may + be removed at any time. + ''' + # Ensure old API still works for now. Note that the API is deprecated + # and this fallback may be removed at any time. + if isinstance(itunes_category, string_types): + itunes_category = {'cat': itunes_category} + if replace: + itunes_category['sub'] = replace + replace = True + if itunes_category is None and kwargs: + itunes_category = kwargs + if itunes_category is not None: + if replace or self.__itunes_category is None: + self.__itunes_category = [] + self.__itunes_category += ensure_format(itunes_category, + set(['cat', 'sub']), + set(['cat'])) + return self.__itunes_category + + def itunes_image(self, itunes_image=None): + '''Get or set the image for the podcast. This tag specifies the artwork + for your podcast. Put the URL to the image in the href attribute. + iTunes prefers square .jpg images that are at least 1400x1400 pixels, + which is different from what is specified for the standard RSS image + tag. In order for a podcast to be eligible for an iTunes Store feature, + the accompanying image must be at least 1400x1400 pixels. + + iTunes supports images in JPEG and PNG formats with an RGB color space + (CMYK is not supported). The URL must end in ".jpg" or ".png". If the + tag is not present, iTunes will use the contents of the + RSS image tag. + + If you change your podcast’s image, also change the file’s name. iTunes + may not change the image if it checks your feed and the image URL is + the same. The server hosting your cover art image must allow HTTP head + requests for iTS to be able to automatically update your cover art. + + :param itunes_image: Image of the podcast. + :returns: Image of the podcast. + ''' + if itunes_image is not None: + if itunes_image.endswith('.jpg') or itunes_image.endswith('.png'): + self.__itunes_image = itunes_image + else: + ValueError('Image file must be png or jpg') + return self.__itunes_image + + def itunes_explicit(self, itunes_explicit=None): + '''Get or the the itunes:explicit value of the podcast. This tag should + be used to indicate whether your podcast contains explicit material. + The three values for this tag are "yes", "no", and "clean". + + If you populate this tag with "yes", an "explicit" parental advisory + graphic will appear next to your podcast artwork on the iTunes Store + and in the Name column in iTunes. If the value is "clean", the parental + advisory type is considered Clean, meaning that no explicit language or + adult content is included anywhere in the episodes, and a "clean" + graphic will appear. If the explicit tag is present and has any other + value (e.g., "no"), you see no indicator — blank is the default + advisory type. + + :param itunes_explicit: If the podcast contains explicit material. + :returns: If the podcast contains explicit material. + ''' + if itunes_explicit is not None: + if itunes_explicit not in ('', 'yes', 'no', 'clean'): + raise ValueError('Invalid value for explicit tag') + self.__itunes_explicit = itunes_explicit + return self.__itunes_explicit + + def itunes_complete(self, itunes_complete=None): + '''Get or set the itunes:complete value of the podcast. This tag can be + used to indicate the completion of a podcast. + + If you populate this tag with "yes", you are indicating that no more + episodes will be added to the podcast. If the tag is + present and has any other value (e.g. “no”), it will have no effect on + the podcast. + + :param itunes_complete: If the podcast is complete. + :returns: If the podcast is complete. + ''' + if itunes_complete is not None: + if itunes_complete not in ('yes', 'no', '', True, False): + raise ValueError('Invalid value for complete tag') + if itunes_complete is True: + itunes_complete = 'yes' + if itunes_complete is False: + itunes_complete = 'no' + self.__itunes_complete = itunes_complete + return self.__itunes_complete + + def itunes_new_feed_url(self, itunes_new_feed_url=None): + '''Get or set the new-feed-url property of the podcast. This tag allows + you to change the URL where the podcast feed is located + + After adding the tag to your old feed, you should maintain the old feed + for 48 hours before retiring it. At that point, iTunes will have + updated the directory with the new feed URL. + + :param itunes_new_feed_url: New feed URL. + :returns: New feed URL. + ''' + if itunes_new_feed_url is not None: + self.__itunes_new_feed_url = itunes_new_feed_url + return self.__itunes_new_feed_url + + def itunes_owner(self, name=None, email=None): + '''Get or set the itunes:owner of the podcast. This tag contains + information that will be used to contact the owner of the podcast for + communication specifically about the podcast. It will not be publicly + displayed. + + :param itunes_owner: The owner of the feed. + :returns: Data of the owner of the feed. + ''' + if name is not None: + if name and email: + self.__itunes_owner = {'name': name, 'email': email} + elif not name and not email: + self.__itunes_owner = None + else: + raise ValueError('Both name and email have to be set.') + return self.__itunes_owner + + def itunes_subtitle(self, itunes_subtitle=None): + '''Get or set the itunes:subtitle value for the podcast. The contents of + this tag are shown in the Description column in iTunes. The subtitle + displays best if it is only a few words long. + + :param itunes_subtitle: Subtitle of the podcast. + :returns: Subtitle of the podcast. + ''' + if itunes_subtitle is not None: + self.__itunes_subtitle = itunes_subtitle + return self.__itunes_subtitle + + def itunes_summary(self, itunes_summary=None): + '''Get or set the itunes:summary value for the podcast. The contents of + this tag are shown in a separate window that appears when the "circled + i" in the Description column is clicked. It also appears on the iTunes + page for your podcast. This field can be up to 4000 characters. If + `` is not included, the contents of the + tag are used. + + :param itunes_summary: Summary of the podcast. + :returns: Summary of the podcast. + ''' + if itunes_summary is not None: + self.__itunes_summary = itunes_summary + return self.__itunes_summary + + _itunes_categories = { + 'Arts': [ + 'Design', 'Fashion & Beauty', 'Food', 'Literature', + 'Performing Arts', 'Visual Arts'], + 'Business': [ + 'Business News', 'Careers', 'Investing', + 'Management & Marketing', 'Shopping'], + 'Comedy': [], + 'Education': [ + 'Education', 'Education Technology', 'Higher Education', + 'K-12', 'Language Courses', 'Training'], + 'Games & Hobbies': [ + 'Automotive', 'Aviation', 'Hobbies', 'Other Games', + 'Video Games'], + 'Government & Organizations': [ + 'Local', 'National', 'Non-Profit', 'Regional'], + 'Health': [ + 'Alternative Health', 'Fitness & Nutrition', 'Self-Help', + 'Sexuality'], + 'Kids & Family': [], + 'Music': [], + 'News & Politics': [], + 'Religion & Spirituality': [ + 'Buddhism', 'Christianity', 'Hinduism', 'Islam', 'Judaism', + 'Other', 'Spirituality'], + 'Science & Medicine': [ + 'Medicine', 'Natural Sciences', 'Social Sciences'], + 'Society & Culture': [ + 'History', 'Personal Journals', 'Philosophy', + 'Places & Travel'], + 'Sports & Recreation': [ + 'Amateur', 'College & High School', 'Outdoor', 'Professional'], + 'Technology': [ + 'Gadgets', 'Tech News', 'Podcasting', 'Software How-To'], + 'TV & Film': []} diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 179694c..8a89e74 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- ''' - feedgen.ext.podcast_entry - ~~~~~~~~~~~~~~~~~~~~~~~~~ + feedgen.ext.podcast_entry + ~~~~~~~~~~~~~~~~~~~~~~~~~ - Extends the feedgen to produce podcasts. + Extends the feedgen to produce podcasts. - :copyright: 2013, Lars Kiesow + :copyright: 2013-2016, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' from lxml import etree @@ -15,229 +15,230 @@ from feedgen.ext.base import BaseEntryExtension class PodcastEntryExtension(BaseEntryExtension): - '''FeedEntry extension for podcasts. - ''' + '''FeedEntry extension for podcasts. + ''' + def __init__(self): + # ITunes tags + # http://www.apple.com/itunes/podcasts/specs.html#rss + self.__itunes_author = None + self.__itunes_block = None + self.__itunes_image = None + self.__itunes_duration = None + self.__itunes_explicit = None + self.__itunes_is_closed_captioned = None + self.__itunes_order = None + self.__itunes_subtitle = None + self.__itunes_summary = None - def __init__(self): - ## ITunes tags - # http://www.apple.com/itunes/podcasts/specs.html#rss - self.__itunes_author = None - self.__itunes_block = None - self.__itunes_image = None - self.__itunes_duration = None - self.__itunes_explicit = None - self.__itunes_is_closed_captioned = None - self.__itunes_order = None - self.__itunes_subtitle = None - self.__itunes_summary = None + def extend_rss(self, entry): + '''Add additional fields to an RSS item. + :param feed: The RSS item XML element to use. + ''' + ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' - def extend_rss(self, entry): - '''Add additional fields to an RSS item. + if self.__itunes_author: + author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) + author.text = self.__itunes_author - :param feed: The RSS item XML element to use. - ''' - ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' + if self.__itunes_block is not None: + block = etree.SubElement(entry, '{%s}block' % ITUNES_NS) + block.text = 'yes' if self.__itunes_block else 'no' - if self.__itunes_author: - author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) - author.text = self.__itunes_author + if self.__itunes_image: + image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) + image.attrib['href'] = self.__itunes_image - if not self.__itunes_block is None: - block = etree.SubElement(entry, '{%s}block' % ITUNES_NS) - block.text = 'yes' if self.__itunes_block else 'no' + if self.__itunes_duration: + duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) + duration.text = self.__itunes_duration - if self.__itunes_image: - image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) - image.attrib['href'] = self.__itunes_image + if self.__itunes_explicit in ('yes', 'no', 'clean'): + explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) + explicit.text = self.__itunes_explicit - if self.__itunes_duration: - duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) - duration.text = self.__itunes_duration + if self.__itunes_is_closed_captioned is not None: + is_closed_captioned = etree.SubElement( + entry, '{%s}isClosedCaptioned' % ITUNES_NS) + if self.__itunes_is_closed_captioned: + is_closed_captioned.text = 'yes' + else: + is_closed_captioned.text = 'no' - if self.__itunes_explicit in ('yes', 'no', 'clean'): - explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) - explicit.text = self.__itunes_explicit + if self.__itunes_order is not None and self.__itunes_order >= 0: + order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) + order.text = str(self.__itunes_order) - if not self.__itunes_is_closed_captioned is None: - is_closed_captioned = etree.SubElement(entry, '{%s}isClosedCaptioned' % ITUNES_NS) - is_closed_captioned.text = 'yes' if self.__itunes_is_closed_captioned else 'no' + if self.__itunes_subtitle: + subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) + subtitle.text = self.__itunes_subtitle - if not self.__itunes_order is None and self.__itunes_order >= 0: - order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) - order.text = str(self.__itunes_order) + if self.__itunes_summary: + summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS) + summary.text = self.__itunes_summary + return entry - if self.__itunes_subtitle: - subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) - subtitle.text = self.__itunes_subtitle + def itunes_author(self, itunes_author=None): + '''Get or set the itunes:author of the podcast episode. The content of + this tag is shown in the Artist column in iTunes. If the tag is not + present, iTunes uses the contents of the tag. If + is not present at the feed level, iTunes will use the + contents of . - if self.__itunes_summary: - summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS) - summary.text = self.__itunes_summary - return entry + :param itunes_author: The author of the podcast. + :returns: The author of the podcast. + ''' + if itunes_author is not None: + self.__itunes_author = itunes_author + return self.__itunes_author + def itunes_block(self, itunes_block=None): + '''Get or set the ITunes block attribute. Use this to prevent episodes + from appearing in the iTunes podcast directory. - def itunes_author(self, itunes_author=None): - '''Get or set the itunes:author of the podcast episode. The content of - this tag is shown in the Artist column in iTunes. If the tag is not - present, iTunes uses the contents of the tag. If - is not present at the feed level, iTunes will use the contents of - . + :param itunes_block: Block podcast episodes. + :returns: If the podcast episode is blocked. + ''' + if itunes_block is not None: + self.__itunes_block = itunes_block + return self.__itunes_block - :param itunes_author: The author of the podcast. - :returns: The author of the podcast. - ''' - if not itunes_author is None: - self.__itunes_author = itunes_author - return self.__itunes_author + def itunes_image(self, itunes_image=None): + '''Get or set the image for the podcast episode. This tag specifies the + artwork for your podcast. Put the URL to the image in the href + attribute. iTunes prefers square .jpg images that are at least + 1400x1400 pixels, which is different from what is specified for the + standard RSS image tag. In order for a podcast to be eligible for an + iTunes Store feature, the accompanying image must be at least 1400x1400 + pixels. + iTunes supports images in JPEG and PNG formats with an RGB color space + (CMYK is not supported). The URL must end in ".jpg" or ".png". If the + tag is not present, iTunes will use the contents of the + RSS image tag. - def itunes_block(self, itunes_block=None): - '''Get or set the ITunes block attribute. Use this to prevent episodes - from appearing in the iTunes podcast directory. + If you change your podcast’s image, also change the file’s name. iTunes + may not change the image if it checks your feed and the image URL is + the same. The server hosting your cover art image must allow HTTP head + requests for iTS to be able to automatically update your cover art. - :param itunes_block: Block podcast episodes. - :returns: If the podcast episode is blocked. - ''' - if not itunes_block is None: - self.__itunes_block = itunes_block - return self.__itunes_block + :param itunes_image: Image of the podcast. + :returns: Image of the podcast. + ''' + if itunes_image is not None: + if itunes_image.endswith('.jpg') or itunes_image.endswith('.png'): + self.__itunes_image = itunes_image + else: + ValueError('Image file must be png or jpg') + return self.__itunes_image + def itunes_duration(self, itunes_duration=None): + '''Get or set the duration of the podcast episode. The content of this + tag is shown in the Time column in iTunes. - def itunes_image(self, itunes_image=None): - '''Get or set the image for the podcast episode. This tag specifies the - artwork for your podcast. Put the URL to the image in the href attribute. - iTunes prefers square .jpg images that are at least 1400x1400 pixels, - which is different from what is specified for the standard RSS image tag. - In order for a podcast to be eligible for an iTunes Store feature, the - accompanying image must be at least 1400x1400 pixels. + The tag can be formatted HH:MM:SS, H:MM:SS, MM:SS, or M:SS (H = hours, + M = minutes, S = seconds). If an integer is provided (no colon + present), the value is assumed to be in seconds. If one colon is + present, the number to the left is assumed to be minutes, and the + number to the right is assumed to be seconds. If more than two colons + are present, the numbers farthest to the right are ignored. - iTunes supports images in JPEG and PNG formats with an RGB color space - (CMYK is not supported). The URL must end in ".jpg" or ".png". If the - tag is not present, iTunes will use the contents of the - RSS image tag. + :param itunes_duration: Duration of the podcast episode. + :returns: Duration of the podcast episode. + ''' + if itunes_duration is not None: + itunes_duration = str(itunes_duration) + if len(itunes_duration.split(':')) > 3 or \ + itunes_duration.lstrip('0123456789:') != '': + ValueError('Invalid duration format') + self.__itunes_duration = itunes_duration + return self.itunes_duration - If you change your podcast’s image, also change the file’s name. iTunes - may not change the image if it checks your feed and the image URL is the - same. The server hosting your cover art image must allow HTTP head - requests for iTS to be able to automatically update your cover art. + def itunes_explicit(self, itunes_explicit=None): + '''Get or the the itunes:explicit value of the podcast episode. This + tag should be used to indicate whether your podcast episode contains + explicit material. The three values for this tag are "yes", "no", and + "clean". - :param itunes_image: Image of the podcast. - :returns: Image of the podcast. - ''' - if not itunes_image is None: - if not ( itunes_image.endswith('.jpg') or itunes_image.endswith('.png') ): - ValueError('Image file must be png or jpg') - self.__itunes_image = itunes_image - return self.__itunes_image + If you populate this tag with "yes", an "explicit" parental advisory + graphic will appear next to your podcast artwork on the iTunes Store + and in the Name column in iTunes. If the value is "clean", the parental + advisory type is considered Clean, meaning that no explicit language or + adult content is included anywhere in the episodes, and a "clean" + graphic will appear. If the explicit tag is present and has any other + value (e.g., "no"), you see no indicator — blank is the default + advisory type. + :param itunes_explicit: If the podcast episode contains explicit + material. + :returns: If the podcast episode contains explicit material. + ''' + if itunes_explicit is not None: + if itunes_explicit not in ('', 'yes', 'no', 'clean'): + raise ValueError('Invalid value for explicit tag') + self.__itunes_explicit = itunes_explicit + return self.__itunes_explicit - def itunes_duration(self, itunes_duration=None): - '''Get or set the duration of the podcast episode. The content of this - tag is shown in the Time column in iTunes. + def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None): + '''Get or set the is_closed_captioned value of the podcast episode. + This tag should be used if your podcast includes a video episode with + embedded closed captioning support. The two values for this tag are + "yes" and "no”. - The tag can be formatted HH:MM:SS, H:MM:SS, MM:SS, or M:SS (H = hours, - M = minutes, S = seconds). If an integer is provided (no colon present), - the value is assumed to be in seconds. If one colon is present, the - number to the left is assumed to be minutes, and the number to the right - is assumed to be seconds. If more than two colons are present, the - numbers farthest to the right are ignored. + :param is_closed_captioned: If the episode has closed captioning + support. + :returns: If the episode has closed captioning support. + ''' + if itunes_is_closed_captioned is not None: + self.__itunes_is_closed_captioned = \ + itunes_is_closed_captioned in ('yes', True) + return self.__itunes_is_closed_captioned - :param itunes_duration: Duration of the podcast episode. - :returns: Duration of the podcast episode. - ''' - if not itunes_duration is None: - itunes_duration = str(itunes_duration) - if len(itunes_duration.split(':')) > 3 or \ - itunes_duration.lstrip('0123456789:') != '': - ValueError('Invalid duration format') - self.__itunes_duration = itunes_duration - return self.itunes_duration + def itunes_order(self, itunes_order=None): + '''Get or set the itunes:order value of the podcast episode. This tag + can be used to override the default ordering of episodes on the store. + This tag is used at an level by populating with the number value + in which you would like the episode to appear on the store. For + example, if you would like an to appear as the first episode in + the podcast, you would populate the tag with “1”. If + conflicting order values are present in multiple episodes, the store + will use default ordering (pubDate). - def itunes_explicit(self, itunes_explicit=None): - '''Get or the the itunes:explicit value of the podcast episode. This tag - should be used to indicate whether your podcast episode contains explicit - material. The three values for this tag are "yes", "no", and "clean". + To remove the order from the episode set the order to a value below + zero. - If you populate this tag with "yes", an "explicit" parental advisory - graphic will appear next to your podcast artwork on the iTunes Store and - in the Name column in iTunes. If the value is "clean", the parental - advisory type is considered Clean, meaning that no explicit language or - adult content is included anywhere in the episodes, and a "clean" graphic - will appear. If the explicit tag is present and has any other value - (e.g., "no"), you see no indicator — blank is the default advisory type. + :param itunes_order: The order of the episode. + :returns: The order of the episode. + ''' + if itunes_order is not None: + self.__itunes_order = int(itunes_order) + return self.__itunes_order - :param itunes_explicit: If the podcast episode contains explicit material. - :returns: If the podcast episode contains explicit material. - ''' - if not itunes_explicit is None: - if not itunes_explicit in ('', 'yes', 'no', 'clean'): - raise ValueError('Invalid value for explicit tag') - self.__itunes_explicit = itunes_explicit - return self.__itunes_explicit + def itunes_subtitle(self, itunes_subtitle=None): + '''Get or set the itunes:subtitle value for the podcast episode. The + contents of this tag are shown in the Description column in iTunes. The + subtitle displays best if it is only a few words long. + :param itunes_subtitle: Subtitle of the podcast episode. + :returns: Subtitle of the podcast episode. + ''' + if itunes_subtitle is not None: + self.__itunes_subtitle = itunes_subtitle + return self.__itunes_subtitle - def itunes_is_closed_captioned(self, itunes_is_closed_captioned=None): - '''Get or set the is_closed_captioned value of the podcast episode. This - tag should be used if your podcast includes a video episode with embedded - closed captioning support. The two values for this tag are "yes" and - "no”. + def itunes_summary(self, itunes_summary=None): + '''Get or set the itunes:summary value for the podcast episode. The + contents of this tag are shown in a separate window that appears when + the "circled i" in the Description column is clicked. It also appears + on the iTunes page for your podcast. This field can be up to 4000 + characters. If is not included, the contents of the + tag are used. - :param is_closed_captioned: If the episode has closed captioning support. - :returns: If the episode has closed captioning support. - ''' - if not itunes_is_closed_captioned is None: - self.__itunes_is_closed_captioned = itunes_is_closed_captioned in ('yes', True) - return self.__itunes_is_closed_captioned - - - def itunes_order(self, itunes_order=None): - '''Get or set the itunes:order value of the podcast episode. This tag can - be used to override the default ordering of episodes on the store. - - This tag is used at an level by populating with the number value - in which you would like the episode to appear on the store. For example, - if you would like an to appear as the first episode in the - podcast, you would populate the tag with “1”. If - conflicting order values are present in multiple episodes, the store will - use default ordering (pubDate). - - To remove the order from the episode set the order to a value below zero. - - :param itunes_order: The order of the episode. - :returns: The order of the episode. - ''' - if not itunes_order is None: - self.__itunes_order = int(itunes_order) - return self.__itunes_order - - - def itunes_subtitle(self, itunes_subtitle=None): - '''Get or set the itunes:subtitle value for the podcast episode. The - contents of this tag are shown in the Description column in iTunes. The - subtitle displays best if it is only a few words long. - - :param itunes_subtitle: Subtitle of the podcast episode. - :returns: Subtitle of the podcast episode. - ''' - if not itunes_subtitle is None: - self.__itunes_subtitle = itunes_subtitle - return self.__itunes_subtitle - - - def itunes_summary(self, itunes_summary=None): - '''Get or set the itunes:summary value for the podcast episode. The - contents of this tag are shown in a separate window that appears when the - "circled i" in the Description column is clicked. It also appears on the - iTunes page for your podcast. This field can be up to 4000 characters. If - is not included, the contents of the tag - are used. - - :param itunes_summary: Summary of the podcast episode. - :returns: Summary of the podcast episode. - ''' - if not itunes_summary is None: - self.__itunes_summary = itunes_summary - return self.__itunes_summary + :param itunes_summary: Summary of the podcast episode. + :returns: Summary of the podcast episode. + ''' + if itunes_summary is not None: + self.__itunes_summary = itunes_summary + return self.__itunes_summary diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index b7f5f49..29e8b4b 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -1,127 +1,126 @@ # -*- coding: utf-8 -*- ''' - feedgen.ext.torrent - ~~~~~~~~~~~~~~~~~~~ + feedgen.ext.torrent + ~~~~~~~~~~~~~~~~~~~ - Extends the FeedGenerator to produce torrent feeds. + Extends the FeedGenerator to produce torrent feeds. - :copyright: 2016, Raspbeguy + :copyright: 2016, Raspbeguy - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' from lxml import etree -from feedgen.ext.base import BaseExtension,BaseEntryExtension +from feedgen.ext.base import BaseExtension, BaseEntryExtension TORRENT_NS = 'http://xmlns.ezrss.it/0.1/dtd/' + class TorrentExtension(BaseExtension): - '''FeedGenerator extension for torrent feeds. - ''' - def extend_ns(self): - return {'torrent' : TORRENT_NS} + '''FeedGenerator extension for torrent feeds. + ''' + def extend_ns(self): + return {'torrent': TORRENT_NS} class TorrentEntryExtension(BaseEntryExtension): - '''FeedEntry extention for torrent feeds - ''' - def __init__(self): - self.__torrent_filename = None - self.__torrent_infohash = None - self.__torrent_contentlength = None - self.__torrent_seeds = None - self.__torrent_peers = None - self.__torrent=verified = None - + '''FeedEntry extention for torrent feeds + ''' + def __init__(self): + self.__torrent_filename = None + self.__torrent_infohash = None + self.__torrent_contentlength = None + self.__torrent_seeds = None + self.__torrent_peers = None - def extend_rss(self, entry): - '''Add additional fields to an RSS item. + def extend_rss(self, entry): + '''Add additional fields to an RSS item. - :param feed: The RSS item XML element to use. - ''' - if self.__torrent_filename: - filename = etree.SubElement(entry, '{%s}filename' % TORRENT_NS) - filename.text = self.__torrent_filename + :param feed: The RSS item XML element to use. + ''' + if self.__torrent_filename: + filename = etree.SubElement(entry, '{%s}filename' % TORRENT_NS) + filename.text = self.__torrent_filename - if self.__torrent_contentlength: - contentlength = etree.SubElement(entry, '{%s}contentlength' % TORRENT_NS) - contentlength.text = self.__torrent_contentlength + if self.__torrent_contentlength: + contentlength = etree.SubElement(entry, + '{%s}contentlength' % TORRENT_NS) + contentlength.text = self.__torrent_contentlength - if self.__torrent_infohash: - infohash = etree.SubElement(entry, '{%s}infohash' % TORRENT_NS) - infohash.text = self.__torrent_infohash - magnet = etree.SubElement(entry, '{%s}magneturi' % TORRENT_NS) - magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_infohash + if self.__torrent_infohash: + infohash = etree.SubElement(entry, '{%s}infohash' % TORRENT_NS) + infohash.text = self.__torrent_infohash + magnet = etree.SubElement(entry, '{%s}magneturi' % TORRENT_NS) + magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_infohash - if self.__torrent_seeds: - seeds = etree.SubElement(entry, '{%s}seed' % TORRENT_NS) - seeds.text = self.__torrent_seeds + if self.__torrent_seeds: + seeds = etree.SubElement(entry, '{%s}seed' % TORRENT_NS) + seeds.text = self.__torrent_seeds - if self.__torrent_peers: - peers = etree.SubElement(entry, '{%s}peers' % TORRENT_NS) - peers.text = self.__torrent_peers + if self.__torrent_peers: + peers = etree.SubElement(entry, '{%s}peers' % TORRENT_NS) + peers.text = self.__torrent_peers - if self.__torrent_seeds: - verified = etree.SubElement(entry, '{%s}verified' % TORRENT_NS) - verified.text = self.__torrent_verified + if self.__torrent_seeds: + verified = etree.SubElement(entry, '{%s}verified' % TORRENT_NS) + verified.text = self.__torrent_verified + def filename(self, torrent_filename=None): + '''Get or set the name of the torrent file. - def filename(self, torrent_filename=None): - '''Get or set the name of the torrent file. + :param torrent_filename: The name of the torrent file. + :returns: The name of the torrent file. + ''' + if torrent_filename is not None: + self.__torrent_filename = torrent_filename + return self.__torrent_filename - :param torrent_filename: The name of the torrent file. - :returns: The name of the torrent file. - ''' - if not torrent_filename is None: - self.__torrent_filename = torrent_filename - return self.__torrent_filename + def infohash(self, torrent_infohash=None): + '''Get or set the hash of the target file. - def infohash (self, torrent_infohash=None): - '''Get or set the hash of the target file. + :param torrent_infohash: The target file hash. + :returns: The target hash file. + ''' + if torrent_infohash is not None: + self.__torrent_infohash = torrent_infohash + return self.__torrent_infohash - :param torrent_infohash: The target file hash. - :returns: The target hash file. - ''' - if not torrent_infohash is None: - self.__torrent_infohash = torrent_infohash - return self.__torrent_infohash + def contentlength(self, torrent_contentlength=None): + '''Get or set the size of the target file. - def contentlength (self, torrent_contentlength=None): - '''Get or set the size of the target file. + :param torrent_contentlength: The target file size. + :returns: The target file size. + ''' + if torrent_contentlength is not None: + self.__torrent_contentlength = torrent_contentlength + return self.__torrent_contentlength - :param torrent_contentlength: The target file size. - :returns: The target file size. - ''' - if not torrent_contentlength is None: - self.__torrent_contentlength = torrent_contentlength - return self.__torrent_contentlength + def seeds(self, torrent_seeds=None): + '''Get or set the number of seeds. - def seeds (self, torrent_seeds=None): - '''Get or set the number of seeds. + :param torrent_seeds: The seeds number. + :returns: The seeds number. + ''' + if torrent_seeds is not None: + self.__torrent_seeds = torrent_seeds + return self.__torrent_seeds - :param torrent_seeds: The seeds number. - :returns: The seeds number. - ''' - if not torrent_seeds is None: - self.__torrent_seeds = torrent_seeds - return self.__torrent_seeds + def peers(self, torrent_peers=None): + '''Get or set the number od peers - def peers (self, torrent_peers=None): - '''Get or set the number od peers + :param torrent_infohash: The peers number. + :returns: The peers number. + ''' + if torrent_peers is not None: + self.__torrent_peers = torrent_peers + return self.__torrent_peers - :param torrent_infohash: The peers number. - :returns: The peers number. - ''' - if not torrent_peers is None: - self.__torrent_peers = torrent_peers - return self.__torrent_peers + def verified(self, torrent_verified=None): + '''Get or set the number of verified peers. - def verified (self, torrent_verified=None): - '''Get or set the number of verified peers. - - :param torrent_infohash: The verified peers number. - :returns: The verified peers number. - ''' - if not torrent_verified is None: - self.__torrent_verified = torrent_verified - return self.__torrent_verified + :param torrent_infohash: The verified peers number. + :returns: The verified peers number. + ''' + if torrent_verified is not None: + self.__torrent_verified = torrent_verified + return self.__torrent_verified diff --git a/feedgen/feed.py b/feedgen/feed.py index 58f1847..1faf3e3 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- ''' - feedgen.feed - ~~~~~~~~~~~~ + feedgen.feed + ~~~~~~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013-2016, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -24,1162 +24,1146 @@ _feedgen_version = feedgen.version.version_str class FeedGenerator(object): - '''FeedGenerator for generating ATOM and RSS feeds. - ''' - - - def __init__(self): - self.__feed_entries = [] - - ## ATOM - # http://www.atomenabled.org/developers/syndication/ - # required - self.__atom_id = None - self.__atom_title = None - self.__atom_updated = datetime.now(dateutil.tz.tzutc()) - - # recommended - self.__atom_author = None # {name*, uri, email} - self.__atom_link = None # {href*, rel, type, hreflang, title, length} - - # optional - self.__atom_category = None # {term*, scheme, label} - self.__atom_contributor = None - self.__atom_generator = { - 'value' :'python-feedgen', - 'uri' :'http://lkiesow.github.io/python-feedgen', - 'version':feedgen.version.version_str } #{value*,uri,version} - self.__atom_icon = None - self.__atom_logo = None - self.__atom_rights = None - self.__atom_subtitle = None - - # other - self.__atom_feed_xml_lang = None - - ## RSS - # http://www.rssboard.org/rss-specification - self.__rss_title = None - self.__rss_link = None - self.__rss_description = None - - self.__rss_category = None - self.__rss_cloud = None - self.__rss_copyright = None - self.__rss_docs = 'http://www.rssboard.org/rss-specification' - self.__rss_generator = 'python-feedgen' - self.__rss_image = None - self.__rss_language = None - self.__rss_lastBuildDate = datetime.now(dateutil.tz.tzutc()) - self.__rss_managingEditor = None - self.__rss_pubDate = None - self.__rss_rating = None - self.__rss_skipHours = None - self.__rss_skipDays = None - self.__rss_textInput = None - self.__rss_ttl = None - self.__rss_webMaster = None - - # Extension list: - self.__extensions = {} - - - def _create_atom(self, extensions=True): - '''Create a ATOM feed xml structure containing all previously set fields. - - :returns: Tuple containing the feed root element and the element tree. - ''' - nsmap = dict() - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('atom'): - nsmap.update( ext['inst'].extend_ns() ) - - feed = etree.Element('feed', xmlns='http://www.w3.org/2005/Atom', nsmap=nsmap) - if self.__atom_feed_xml_lang: - feed.attrib['{http://www.w3.org/XML/1998/namespace}lang'] = \ - self.__atom_feed_xml_lang - - if not ( self.__atom_id and self.__atom_title and self.__atom_updated ): - missing = ', '.join(([] if self.__atom_title else ['title']) + \ - ([] if self.__atom_id else ['id']) + \ - ([] if self.__atom_updated else ['updated'])) - raise ValueError('Required fields not set (%s)' % missing) - id = etree.SubElement(feed, 'id') - id.text = self.__atom_id - title = etree.SubElement(feed, 'title') - title.text = self.__atom_title - updated = etree.SubElement(feed, 'updated') - updated.text = self.__atom_updated.isoformat() - - # Add author elements - for a in self.__atom_author or []: - # Atom requires a name. Skip elements without. - if not a.get('name'): - continue - author = etree.SubElement(feed, 'author') - name = etree.SubElement(author, 'name') - name.text = a.get('name') - if a.get('email'): - email = etree.SubElement(author, 'email') - email.text = a.get('email') - if a.get('uri'): - uri = etree.SubElement(author, 'uri') - uri.text = a.get('uri') - - for l in self.__atom_link or []: - link = etree.SubElement(feed, 'link', href=l['href']) - if l.get('rel'): - link.attrib['rel'] = l['rel'] - if l.get('type'): - link.attrib['type'] = l['type'] - if l.get('hreflang'): - link.attrib['hreflang'] = l['hreflang'] - if l.get('title'): - link.attrib['title'] = l['title'] - if l.get('length'): - link.attrib['length'] = l['length'] - - for c in self.__atom_category or []: - cat = etree.SubElement(feed, 'category', term=c['term']) - if c.get('scheme'): - cat.attrib['scheme'] = c['scheme'] - if c.get('label'): - cat.attrib['label'] = c['label'] - - # Add author elements - for c in self.__atom_contributor or []: - # Atom requires a name. Skip elements without. - if not c.get('name'): - continue - contrib = etree.SubElement(feed, 'contributor') - name = etree.SubElement(contrib, 'name') - name.text = c.get('name') - if c.get('email'): - email = etree.SubElement(contrib, 'email') - email.text = c.get('email') - if c.get('uri'): - uri = etree.SubElement(contrib, 'uri') - uri.text = c.get('uri') - - if self.__atom_generator: - generator = etree.SubElement(feed, 'generator') - generator.text = self.__atom_generator['value'] - if self.__atom_generator.get('uri'): - generator.attrib['uri'] = self.__atom_generator['uri'] - if self.__atom_generator.get('version'): - generator.attrib['version'] = self.__atom_generator['version'] - - if self.__atom_icon: - icon = etree.SubElement(feed, 'icon') - icon.text = self.__atom_icon - - if self.__atom_logo: - logo = etree.SubElement(feed, 'logo') - logo.text = self.__atom_logo - - if self.__atom_rights: - rights = etree.SubElement(feed, 'rights') - rights.text = self.__atom_rights - - if self.__atom_subtitle: - subtitle = etree.SubElement(feed, 'subtitle') - subtitle.text = self.__atom_subtitle - - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('atom'): - ext['inst'].extend_atom(feed) - - for entry in self.__feed_entries: - entry = entry.atom_entry() - feed.append(entry) - - doc = etree.ElementTree(feed) - return feed, doc - - - def atom_str(self, pretty=False, extensions=True, encoding='UTF-8', - xml_declaration=True): - '''Generates an ATOM feed and returns the feed XML as string. - - :param pretty: If the feed should be split into multiple lines and - properly indented. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :returns: String representation of the ATOM feed. - - **Return type:** The return type may vary between different Python - versions and your encoding parameters passed to this method. For details - have a look at the `lxml documentation - `_ - ''' - feed, doc = self._create_atom(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def atom_file(self, filename, extensions=True, pretty=False, - encoding='UTF-8', xml_declaration=True): - '''Generates an ATOM feed and write the resulting XML to a file. - - :param filename: Name of file to write, or a file-like object, or a URL. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param pretty: If the feed should be split into multiple lines and - properly indented. - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - ''' - feed, doc = self._create_atom(extensions=extensions) - doc.write(filename, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def _create_rss(self, extensions=True): - '''Create an RSS feed xml structure containing all previously set fields. - - :returns: Tuple containing the feed root element and the element tree. - ''' - nsmap = dict() - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('rss'): - nsmap.update( ext['inst'].extend_ns() ) - - nsmap.update({'atom': 'http://www.w3.org/2005/Atom', - 'content': 'http://purl.org/rss/1.0/modules/content/'}) - - feed = etree.Element('rss', version='2.0', nsmap=nsmap ) - channel = etree.SubElement(feed, 'channel') - if not ( self.__rss_title and self.__rss_link and self.__rss_description ): - missing = ', '.join(([] if self.__rss_title else ['title']) + \ - ([] if self.__rss_link else ['link']) + \ - ([] if self.__rss_description else ['description'])) - raise ValueError('Required fields not set (%s)' % missing) - title = etree.SubElement(channel, 'title') - title.text = self.__rss_title - link = etree.SubElement(channel, 'link') - link.text = self.__rss_link - desc = etree.SubElement(channel, 'description') - desc.text = self.__rss_description - for ln in self.__atom_link or []: - # It is recommended to include a atom self link in rss documents… - if ln.get('rel') == 'self': - selflink = etree.SubElement(channel, - '{http://www.w3.org/2005/Atom}link', - href=ln['href'], rel='self') - if ln.get('type'): - selflink.attrib['type'] = ln['type'] - if ln.get('hreflang'): - selflink.attrib['hreflang'] = ln['hreflang'] - if ln.get('title'): - selflink.attrib['title'] = ln['title'] - if ln.get('length'): - selflink.attrib['length'] = ln['length'] - break - if self.__rss_category: - for cat in self.__rss_category: - category = etree.SubElement(channel, 'category') - category.text = cat['value'] - if cat.get('domain'): - category.attrib['domain'] = cat['domain'] - if self.__rss_cloud: - cloud = etree.SubElement(channel, 'cloud') - cloud.attrib['domain'] = self.__rss_cloud.get('domain') - cloud.attrib['port'] = self.__rss_cloud.get('port') - cloud.attrib['path'] = self.__rss_cloud.get('path') - cloud.attrib['registerProcedure'] = self.__rss_cloud.get( - 'registerProcedure') - cloud.attrib['protocol'] = self.__rss_cloud.get('protocol') - if self.__rss_copyright: - copyright = etree.SubElement(channel, 'copyright') - copyright.text = self.__rss_copyright - if self.__rss_docs: - docs = etree.SubElement(channel, 'docs') - docs.text = self.__rss_docs - if self.__rss_generator: - generator = etree.SubElement(channel, 'generator') - generator.text = self.__rss_generator - if self.__rss_image: - image = etree.SubElement(channel, 'image') - url = etree.SubElement(image, 'url') - url.text = self.__rss_image.get('url') - title = etree.SubElement(image, 'title') - title.text = self.__rss_image['title'] \ - if self.__rss_image.get('title') else self.__rss_title - link = etree.SubElement(image, 'link') - link.text = self.__rss_image['link'] \ - if self.__rss_image.get('link') else self.__rss_link - if self.__rss_image.get('width'): - width = etree.SubElement(image, 'width') - width.text = self.__rss_image.get('width') - if self.__rss_image.get('height'): - height = etree.SubElement(image, 'height') - height.text = self.__rss_image.get('height') - if self.__rss_image.get('description'): - description = etree.SubElement(image, 'description') - description.text = self.__rss_image.get('description') - if self.__rss_language: - language = etree.SubElement(channel, 'language') - language.text = self.__rss_language - if self.__rss_lastBuildDate: - lastBuildDate = etree.SubElement(channel, 'lastBuildDate') - - lastBuildDate.text = formatRFC2822(self.__rss_lastBuildDate) - if self.__rss_managingEditor: - managingEditor = etree.SubElement(channel, 'managingEditor') - managingEditor.text = self.__rss_managingEditor - if self.__rss_pubDate: - pubDate = etree.SubElement(channel, 'pubDate') - pubDate.text = formatRFC2822(self.__rss_pubDate) - if self.__rss_rating: - rating = etree.SubElement(channel, 'rating') - rating.text = self.__rss_rating - if self.__rss_skipHours: - skipHours = etree.SubElement(channel, 'skipHours') - for h in self.__rss_skipHours: - hour = etree.SubElement(skipHours, 'hour') - hour.text = str(h) - if self.__rss_skipDays: - skipDays = etree.SubElement(channel, 'skipDays') - for d in self.__rss_skipDays: - day = etree.SubElement(skipDays, 'day') - day.text = d - if self.__rss_textInput: - textInput = etree.SubElement(channel, 'textInput') - textInput.attrib['title'] = self.__rss_textInput.get('title') - textInput.attrib['description'] = self.__rss_textInput.get('description') - textInput.attrib['name'] = self.__rss_textInput.get('name') - textInput.attrib['link'] = self.__rss_textInput.get('link') - if self.__rss_ttl: - ttl = etree.SubElement(channel, 'ttl') - ttl.text = str(self.__rss_ttl) - if self.__rss_webMaster: - webMaster = etree.SubElement(channel, 'webMaster') - webMaster.text = self.__rss_webMaster - - if extensions: - for ext in self.__extensions.values() or []: - if ext.get('rss'): - ext['inst'].extend_rss(feed) - - for entry in self.__feed_entries: - item = entry.rss_entry() - channel.append(item) - - doc = etree.ElementTree(feed) - return feed, doc - - - def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', - xml_declaration=True): - '''Generates an RSS feed and returns the feed XML as string. - - :param pretty: If the feed should be split into multiple lines and - properly indented. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - :returns: String representation of the RSS feed. - - **Return type:** The return type may vary between different Python - versions and your encoding parameters passed to this method. For details - have a look at the `lxml documentation - `_ - ''' - feed, doc = self._create_rss(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def rss_file(self, filename, extensions=True, pretty=False, - encoding='UTF-8', xml_declaration=True): - '''Generates an RSS feed and write the resulting XML to a file. - - :param filename: Name of file to write, or a file-like object, or a URL. - :param extensions: Enable or disable the loaded extensions for the xml - generation (default: enabled). - :param pretty: If the feed should be split into multiple lines and - properly indented. - :param encoding: Encoding used in the XML file (default: UTF-8). - :param xml_declaration: If an XML declaration should be added to the - output (Default: enabled). - ''' - feed, doc = self._create_rss(extensions=extensions) - doc.write(filename, pretty_print=pretty, encoding=encoding, - xml_declaration=xml_declaration) - - - def title(self, title=None): - '''Get or set the title value of the feed. It should contain a human - readable title for the feed. Often the same as the title of the - associated website. Title is mandatory for both ATOM and RSS and should - not be blank. - - :param title: The new title of the feed. - :returns: The feeds title. - ''' - if not title is None: - self.__atom_title = title - self.__rss_title = title - return self.__atom_title - - - def id(self, id=None): - '''Get or set the feed id which identifies the feed using a universally - unique and permanent URI. If you have a long-term, renewable lease on - your Internet domain name, then you can feel free to use your website's - address. This field is for ATOM only. It is mandatory for ATOM. - - :param id: New Id of the ATOM feed. - :returns: Id of the feed. - ''' - - if not id is None: - self.__atom_id = id - return self.__atom_id - - - def updated(self, updated=None): - '''Set or get the updated value which indicates the last time the feed - was modified in a significant way. - - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - - This will set both atom:updated and rss:lastBuildDate. - - Default value - If not set, updated has as value the current date and time. - - :param updated: The modification date. - :returns: Modification date as datetime.datetime - ''' - if not updated is None: - if isinstance(updated, string_types): - updated = dateutil.parser.parse(updated) - if not isinstance(updated, datetime): - raise ValueError('Invalid datetime format') - if updated.tzinfo is None: - raise ValueError('Datetime object has no timezone info') - self.__atom_updated = updated - self.__rss_lastBuildDate = updated - - return self.__atom_updated - - - def lastBuildDate(self, lastBuildDate=None): - '''Set or get the lastBuildDate value which indicates the last time the - content of the channel changed. - - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - - This will set both atom:updated and rss:lastBuildDate. - - Default value - If not set, lastBuildDate has as value the current date and time. - - :param lastBuildDate: The modification date. - :returns: Modification date as datetime.datetime - ''' - return self.updated( lastBuildDate ) - - - def author(self, author=None, replace=False, **kwargs): - '''Get or set author data. An author element is a dictionary containing a name, - an email address and a URI. Name is mandatory for ATOM, email is mandatory - for RSS. - - This method can be called with: - - - the fields of an author as keyword arguments - - the fields of an author as a dictionary - - a list of dictionaries containing the author fields - - An author has the following fields: - - - *name* conveys a human-readable name for the person. - - *uri* contains a home page for the person. - - *email* contains an email address for the person. - - :param author: Dictionary or list of dictionaries with author data. - :param replace: Add or replace old data. - :returns: List of authors as dictionaries. - - Example:: - - >>> feedgen.author( { 'name':'John Doe', 'email':'jdoe@example.com' } ) - [{'name':'John Doe','email':'jdoe@example.com'}] - - >>> feedgen.author([{'name':'Mr. X'},{'name':'Max'}]) - [{'name':'John Doe','email':'jdoe@example.com'}, - {'name':'John Doe'}, {'name':'Max'}] - - >>> feedgen.author( name='John Doe', email='jdoe@example.com', replace=True ) - [{'name':'John Doe','email':'jdoe@example.com'}] - - ''' - if author is None and kwargs: - author = kwargs - if not author is None: - if replace or self.__atom_author is None: - self.__atom_author = [] - self.__atom_author += ensure_format( author, - set(['name', 'email', 'uri']), set(['name'])) - self.__rss_author = [] - for a in self.__atom_author: - if a.get('email'): - self.__rss_author.append(a['email']) - return self.__atom_author - - - def link(self, link=None, replace=False, **kwargs): - '''Get or set link data. An link element is a dict with the fields href, - rel, type, hreflang, title, and length. Href is mandatory for ATOM. - - This method can be called with: - - - the fields of a link as keyword arguments - - the fields of a link as a dictionary - - a list of dictionaries containing the link fields - - A link has the following fields: - - - *href* is the URI of the referenced resource (typically a Web page) - - *rel* contains a single link relationship type. It can be a full URI, - or one of the following predefined values (default=alternate): - - - *alternate* an alternate representation of the entry or feed, for - example a permalink to the html version of the entry, or the front - page of the weblog. - - *enclosure* a related resource which is potentially large in size - and might require special handling, for example an audio or video - recording. - - *related* an document related to the entry or feed. - - *self* the feed itself. - - *via* the source of the information provided in the entry. - - - *type* indicates the media type of the resource. - - *hreflang* indicates the language of the referenced resource. - - *title* human readable information about the link, typically for - display purposes. - - *length* the length of the resource, in bytes. - - RSS only supports one link with URL only. - - :param link: Dict or list of dicts with data. - :param replace: If old links are to be replaced (default: False) - :returns: Current set of link data - - Example:: - - >>> feedgen.link( href='http://example.com/', rel='self') - [{'href':'http://example.com/', 'rel':'self'}] - - ''' - if link is None and kwargs: - link = kwargs - if not link is None: - if replace or self.__atom_link is None: - self.__atom_link = [] - self.__atom_link += ensure_format( link, - set(['href', 'rel', 'type', 'hreflang', 'title', 'length']), - set(['href']), - {'rel': [ - 'about', 'alternate', 'appendix', 'archives', 'author', 'bookmark', - 'canonical', 'chapter', 'collection', 'contents', 'copyright', 'create-form', - 'current', 'derivedfrom', 'describedby', 'describes', 'disclosure', - 'duplicate', 'edit', 'edit-form', 'edit-media', 'enclosure', 'first', 'glossary', - 'help', 'hosts', 'hub', 'icon', 'index', 'item', 'last', 'latest-version', 'license', - 'lrdd', 'memento', 'monitor', 'monitor-group', 'next', 'next-archive', 'nofollow', - 'noreferrer', 'original', 'payment', 'predecessor-version', 'prefetch', 'prev', 'preview', - 'previous', 'prev-archive', 'privacy-policy', 'profile', 'related', 'replies', 'search', - 'section', 'self', 'service', 'start', 'stylesheet', 'subsection', 'successor-version', - 'tag', 'terms-of-service', 'timegate', 'timemap', 'type', 'up', 'version-history', 'via', - 'working-copy', 'working-copy-of' - ]}) - # RSS only needs one URL. We use the first link for RSS: - if len(self.__atom_link) > 0: - self.__rss_link = self.__atom_link[-1]['href'] - # return the set with more information (atom) - return self.__atom_link - - - def category(self, category=None, replace=False, **kwargs): - '''Get or set categories that the feed belongs to. - - This method can be called with: - - - the fields of a category as keyword arguments - - the fields of a category as a dictionary - - a list of dictionaries containing the category fields - - A categories has the following fields: - - - *term* identifies the category - - *scheme* identifies the categorization scheme via a URI. - - *label* provides a human-readable label for display - - If a label is present it is used for the RSS feeds. Otherwise the term is - used. The scheme is used for the domain attribute in RSS. - - :param link: Dict or list of dicts with data. - :param replace: Add or replace old data. - :returns: List of category data. - ''' - if category is None and kwargs: - category = kwargs - if not category is None: - if replace or self.__atom_category is None: - self.__atom_category = [] - self.__atom_category += ensure_format( - category, - set(['term', 'scheme', 'label']), - set(['term']) ) - # Map the ATOM categories to RSS categories. Use the atom:label as - # name or if not present the atom:term. The atom:scheme is the - # rss:domain. - self.__rss_category = [] - for cat in self.__atom_category: - rss_cat = {} - rss_cat['value'] = cat['label'] if cat.get('label') else cat['term'] - if cat.get('scheme'): - rss_cat['domain'] = cat['scheme'] - self.__rss_category.append( rss_cat ) - return self.__atom_category - - - def cloud(self, domain=None, port=None, path=None, registerProcedure=None, - protocol=None): - '''Set or get the cloud data of the feed. It is an RSS only attribute. It - specifies a web service that supports the rssCloud interface which can be - implemented in HTTP-POST, XML-RPC or SOAP 1.1. - - :param domain: The domain where the webservice can be found. - :param port: The port the webservice listens to. - :param path: The path of the webservice. - :param registerProcedure: The procedure to call. - :param protocol: Can be either HTTP-POST, XML-RPC or SOAP 1.1. - :returns: Dictionary containing the cloud data. - ''' - if not domain is None: - self.__rss_cloud = {'domain':domain, 'port':port, 'path':path, - 'registerProcedure':registerProcedure, 'protocol':protocol} - return self.__rss_cloud - - - def contributor(self, contributor=None, replace=False, **kwargs): - '''Get or set the contributor data of the feed. This is an ATOM only - value. - - This method can be called with: - - the fields of an contributor as keyword arguments - - the fields of an contributor as a dictionary - - a list of dictionaries containing the contributor fields - - An contributor has the following fields: - - *name* conveys a human-readable name for the person. - - *uri* contains a home page for the person. - - *email* contains an email address for the person. - - :param contributor: Dictionary or list of dictionaries with contributor data. - :param replace: Add or replace old data. - :returns: List of contributors as dictionaries. - ''' - if contributor is None and kwargs: - contributor = kwargs - if not contributor is None: - if replace or self.__atom_contributor is None: - self.__atom_contributor = [] - self.__atom_contributor += ensure_format( contributor, - set(['name', 'email', 'uri']), set(['name'])) - return self.__atom_contributor - - - def generator(self, generator=None, version=None, uri=None): - '''Get or the generator of the feed which identifies the software used to - generate the feed, for debugging and other purposes. Both the uri and - version attributes are optional and only available in the ATOM feed. - - :param generator: Software used to create the feed. - :param version: Version of the software. - :param uri: URI the software can be found. - ''' - if not generator is None: - self.__atom_generator = {'value':generator} - if not version is None: - self.__atom_generator['version'] = version - if not uri is None: - self.__atom_generator['uri'] = uri - self.__rss_generator = generator - return self.__atom_generator - - - def icon(self, icon=None): - '''Get or set the icon of the feed which is a small image which provides - iconic visual identification for the feed. Icons should be square. This - is an ATOM only value. - - :param icon: URI of the feeds icon. - :returns: URI of the feeds icon. - ''' - if not icon is None: - self.__atom_icon = icon - return self.__atom_icon - - - def logo(self, logo=None): - '''Get or set the logo of the feed which is a larger image which provides - visual identification for the feed. Images should be twice as wide as - they are tall. This is an ATOM value but will also set the rss:image - value. - - :param logo: Logo of the feed. - :returns: Logo of the feed. - ''' - if not logo is None: - self.__atom_logo = logo - self.__rss_image = { 'url' : logo } - return self.__atom_logo - - - def image(self, url=None, title=None, link=None, width=None, height=None, - description=None): - '''Set the image of the feed. This element is roughly equivalent to - atom:logo. - - :param url: The URL of a GIF, JPEG or PNG image. - :param title: Describes the image. The default value is the feeds title. - :param link: URL of the site the image will link to. The default is to - use the feeds first altertate link. - :param width: Width of the image in pixel. The maximum is 144. - :param height: The height of the image. The maximum is 400. - :param description: Title of the link. - :returns: Data of the image as dictionary. - ''' - if not url is None: - self.__rss_image = { 'url' : url } - if not title is None: - self.__rss_image['title'] = title - if not link is None: - self.__rss_image['link'] = link - if width: - self.__rss_image['width'] = width - if height: - self.__rss_image['height'] = height - self.__atom_logo = url - return self.__rss_image - - - def rights(self, rights=None): - '''Get or set the rights value of the feed which conveys information - about rights, e.g. copyrights, held in and over the feed. This ATOM value - will also set rss:copyright. - - :param rights: Rights information of the feed. - ''' - if not rights is None: - self.__atom_rights = rights - self.__rss_copyright = rights - return self.__atom_rights - - - def copyright(self, copyright=None): - '''Get or set the copyright notice for content in the channel. This RSS - value will also set the atom:rights value. - - :param copyright: The copyright notice. - :returns: The copyright notice. - ''' - return self.rights( copyright ) - - - def subtitle(self, subtitle=None): - '''Get or set the subtitle value of the cannel which contains a - human-readable description or subtitle for the feed. This ATOM property - will also set the value for rss:description. - - :param subtitle: The subtitle of the feed. - :returns: The subtitle of the feed. - ''' - if not subtitle is None: - self.__atom_subtitle = subtitle - self.__rss_description = subtitle - return self.__atom_subtitle - - - def description(self, description=None): - '''Set and get the description of the feed. This is an RSS only element - which is a phrase or sentence describing the channel. It is mandatory for - RSS feeds. It is roughly the same as atom:subtitle. Thus setting this - will also set atom:subtitle. - - :param description: Description of the channel. - :returns: Description of the channel. - - ''' - return self.subtitle( description ) - - - def docs(self, docs=None): - '''Get or set the docs value of the feed. This is an RSS only value. It - is a URL that points to the documentation for the format used in the RSS - file. It is probably a pointer to [1]. It is for people who might stumble - across an RSS file on a Web server 25 years from now and wonder what it - is. - - [1]: http://www.rssboard.org/rss-specification - - :param docs: URL of the format documentation. - :returns: URL of the format documentation. - ''' - if not docs is None: - self.__rss_docs = docs - return self.__rss_docs - - - def language(self, language=None): - '''Get or set the language of the feed. It indicates the language the - channel is written in. This allows aggregators to group all Italian - language sites, for example, on a single page. This is an RSS only field. - However, this value will also be used to set the xml:lang property of the - ATOM feed node. - The value should be an IETF language tag. - - :param language: Language of the feed. - :returns: Language of the feed. - ''' - if not language is None: - self.__rss_language = language - self.__atom_feed_xml_lang = language - return self.__rss_language - - - def managingEditor(self, managingEditor=None): - '''Set or get the value for managingEditor which is the email address for - person responsible for editorial content. This is a RSS only value. - - :param managingEditor: Email adress of the managing editor. - :returns: Email adress of the managing editor. - ''' - if not managingEditor is None: - self.__rss_managingEditor = managingEditor - return self.__rss_managingEditor - - - def pubDate(self, pubDate=None): - '''Set or get the publication date for the content in the channel. For - example, the New York Times publishes on a daily basis, the publication - date flips once every 24 hours. That's when the pubDate of the channel - changes. - - The value can either be a string which will automatically be parsed or a - datetime.datetime object. In any case it is necessary that the value - include timezone information. - - This will set both atom:updated and rss:lastBuildDate. - - :param pubDate: The publication date. - :returns: Publication date as datetime.datetime - ''' - if not pubDate is None: - if isinstance(pubDate, string_types): - pubDate = dateutil.parser.parse(pubDate) - if not isinstance(pubDate, datetime): - raise ValueError('Invalid datetime format') - if pubDate.tzinfo is None: - raise ValueError('Datetime object has no timezone info') - self.__rss_pubDate = pubDate - - return self.__rss_pubDate - - - def rating(self, rating=None): - '''Set and get the PICS rating for the channel. It is an RSS only - value. - ''' - if not rating is None: - self.__rss_rating = rating - return self.__rss_rating - - - def skipHours(self, hours=None, replace=False): - '''Set or get the value of skipHours, a hint for aggregators telling them - which hours they can skip. This is an RSS only value. - - This method can be called with an hour or a list of hours. The hours are - represented as integer values from 0 to 23. - - :param hours: List of hours the feedreaders should not check the feed. - :param replace: Add or replace old data. - :returns: List of hours the feedreaders should not check the feed. - ''' - if not hours is None: - if not (isinstance(hours, list) or isinstance(hours, set)): - hours = [hours] - for h in hours: - if not h in range(24): - raise ValueError('Invalid hour %s' % h) - if replace or not self.__rss_skipHours: - self.__rss_skipHours = set() - self.__rss_skipHours |= set(hours) - return self.__rss_skipHours - - - def skipDays(self, days=None, replace=False): - '''Set or get the value of skipDays, a hint for aggregators telling them - which days they can skip This is an RSS only value. - - This method can be called with a day name or a list of day names. The days are - represented as strings from 'Monday' to 'Sunday'. - - :param hours: List of days the feedreaders should not check the feed. - :param replace: Add or replace old data. - :returns: List of days the feedreaders should not check the feed. - ''' - if not days is None: - if not (isinstance(days, list) or isinstance(days, set)): - days = [days] - for d in days: - if not d in ['Monday', 'Tuesday', 'Wednesday', 'Thursday', - 'Friday', 'Saturday', 'Sunday']: - raise ValueError('Invalid day %s' % h) - if replace or not self.__rss_skipDays: - self.__rss_skipDays = set() - self.__rss_skipDays |= set(days) - return self.__rss_skipDays - - - def textInput(self, title=None, description=None, name=None, link=None): - '''Get or set the value of textInput. This is an RSS only field. The - purpose of the element is something of a mystery. You can use - it to specify a search engine box. Or to allow a reader to provide - feedback. Most aggregators ignore it. - - :param title: The label of the Submit button in the text input area. - :param description: Explains the text input area. - :param name: The name of the text object in the text input area. - :param link: The URL of the CGI script that processes text input requests. - :returns: Dictionary containing textInput values. - ''' - if not title is None: - self.__rss_textInput = {} - self.__rss_textInput['title'] = title - self.__rss_textInput['description'] = description - self.__rss_textInput['name'] = name - self.__rss_textInput['link'] = link - return self.__rss_textInput - - - def ttl(self, ttl=None): - '''Get or set the ttl value. It is an RSS only element. ttl stands for - time to live. It's a number of minutes that indicates how long a channel - can be cached before refreshing from the source. - - :param ttl: Integer value indicating how long the channel may be cached. - :returns: Time to live. - ''' - if not ttl is None: - self.__rss_ttl = int(ttl) - return self.__rss_ttl - - - def webMaster(self, webMaster=None): - '''Get and set the value of webMaster, which represents the email address - for the person responsible for technical issues relating to the feed. - This is an RSS only value. - - :param webMaster: Email address of the webmaster. - :returns: Email address of the webmaster. - ''' - if not webMaster is None: - self.__rss_webMaster = webMaster - return self.__rss_webMaster - - - def add_entry(self, feedEntry=None): - '''This method will add a new entry to the feed. If the feedEntry - argument is omittet a new Entry object is created automatically. This is - the prefered way to add new entries to a feed. - - :param feedEntry: FeedEntry object to add. - :returns: FeedEntry object created or passed to this function. - - Example:: - - ... - >>> entry = feedgen.add_entry() - >>> entry.title('First feed entry') - - ''' - if feedEntry is None: - feedEntry = FeedEntry() - - version = sys.version_info[0] - - if version == 2: - items = self.__extensions.iteritems() - else: - items = self.__extensions.items() - - # Try to load extensions: - for extname,ext in items: - try: - feedEntry.register_extension(extname, - ext['extension_class_entry'], - ext['atom'], ext['rss'] ) - except ImportError: - pass - - self.__feed_entries.append( feedEntry ) - return feedEntry - - - def add_item(self, item=None): - '''This method will add a new item to the feed. If the item argument is - omittet a new FeedEntry object is created automatically. This is just - another name for add_entry(...) - ''' - return self.add_entry(item) - - - def entry(self, entry=None, replace=False): - '''Get or set feed entries. Use the add_entry() method instead to - automatically create the FeedEntry objects. - - This method takes both a single FeedEntry object or a list of objects. - - :param entry: FeedEntry object or list of FeedEntry objects. - :returns: List ob all feed entries. - ''' - if not entry is None: - if not isinstance(entry, list): - entry = [entry] - if replace: - self.__feed_entries = [] - - version = sys.version_info[0] - - if version == 2: - items = self.__extensions.iteritems() - else: - items = self.__extensions.items() - - # Try to load extensions: - for e in entry: - for extname,ext in items: - try: - e.register_extension(extname, - ext['extension_class_entry'], - ext['atom'], ext['rss'] ) - except ImportError: - pass - - self.__feed_entries += entry - return self.__feed_entries - - - def item(self, item=None, replace=False): - '''Get or set feed items. This is just another name for entry(...) - ''' - return self.entry(item, replace) - - - def remove_entry(self, entry): - '''Remove a single entry from the feed. This method accepts both the - FeedEntry object to remove or the index of the entry as argument. - - :param entry: Entry or index of entry to remove. - ''' - if isinstance(entry, FeedEntry): - self.__feed_entries.remove(entry) - else: - self.__feed_entries.pop(entry) - - - def remove_item(self, item): - '''Remove a single item from the feed. This is another name for - remove_entry. - ''' - self.remove_entry(item) - - - def load_extension(self, name, atom=True, rss=True): - '''Load a specific extension by name. - - :param name: Name of the extension to load. - :param atom: If the extension should be used for ATOM feeds. - :param rss: If the extension should be used for RSS feeds. - ''' - # Check loaded extensions - if not isinstance(self.__extensions, dict): - self.__extensions = {} - if name in self.__extensions.keys(): - raise ImportError('Extension already loaded') - - # Load extension - extname = name[0].upper() + name[1:] - feedsupmod = __import__('feedgen.ext.%s' % name) - feedextmod = getattr(feedsupmod.ext, name) - try: - entrysupmod = __import__('feedgen.ext.%s_entry' % name) - entryextmod = getattr(entrysupmod.ext, name + '_entry') - except ImportError: - # Use FeedExtension module instead - entrysupmod = feedsupmod - entryextmod = feedextmod - feedext = getattr(feedextmod, extname + 'Extension') - try: - entryext = getattr(entryextmod, extname + 'EntryExtension') - except AttributeError: - entryext = None - self.register_extension(name, feedext, entryext, atom, rss) - - - def register_extension(self, namespace, extension_class_feed = None, - extension_class_entry = None, atom=True, rss=True): - '''Registers an extension by class. - - :param namespace: namespace for the extension - :param extension_class_feed: Class of the feed extension to load. - :param extension_class_entry: Class of the entry extension to load - :param atom: If the extension should be used for ATOM feeds. - :param rss: If the extension should be used for RSS feeds. - ''' - # Check loaded extensions - # `load_extension` ignores the "Extension" suffix. - if not isinstance(self.__extensions, dict): - self.__extensions = {} - if namespace in self.__extensions.keys(): - raise ImportError('Extension already loaded') - - # Load extension - extinst = extension_class_feed() - setattr(self, namespace, extinst) - - # `load_extension` registry - self.__extensions[namespace] = { - 'inst':extinst, - 'extension_class_feed': extension_class_feed, - 'extension_class_entry': extension_class_entry, - 'atom':atom, - 'rss':rss - } - - # Try to load the extension for already existing entries: - for entry in self.__feed_entries: - try: - entry.register_extension(namespace, - extension_class_entry, atom, rss) - except ImportError: - pass + '''FeedGenerator for generating ATOM and RSS feeds. + ''' + + def __init__(self): + self.__feed_entries = [] + + # ATOM + # http://www.atomenabled.org/developers/syndication/ + # required + self.__atom_id = None + self.__atom_title = None + self.__atom_updated = datetime.now(dateutil.tz.tzutc()) + + # recommended + self.__atom_author = None # {name*, uri, email} + self.__atom_link = None # {href*, rel, type, hreflang, title, length} + + # optional + self.__atom_category = None # {term*, scheme, label} + self.__atom_contributor = None + self.__atom_generator = { + 'value': 'python-feedgen', + 'uri': 'http://lkiesow.github.io/python-feedgen', + 'version': feedgen.version.version_str} # {value*,uri,version} + self.__atom_icon = None + self.__atom_logo = None + self.__atom_rights = None + self.__atom_subtitle = None + + # other + self.__atom_feed_xml_lang = None + + # RSS + # http://www.rssboard.org/rss-specification + self.__rss_title = None + self.__rss_link = None + self.__rss_description = None + + self.__rss_category = None + self.__rss_cloud = None + self.__rss_copyright = None + self.__rss_docs = 'http://www.rssboard.org/rss-specification' + self.__rss_generator = 'python-feedgen' + self.__rss_image = None + self.__rss_language = None + self.__rss_lastBuildDate = datetime.now(dateutil.tz.tzutc()) + self.__rss_managingEditor = None + self.__rss_pubDate = None + self.__rss_rating = None + self.__rss_skipHours = None + self.__rss_skipDays = None + self.__rss_textInput = None + self.__rss_ttl = None + self.__rss_webMaster = None + + # Extension list: + self.__extensions = {} + + def _create_atom(self, extensions=True): + '''Create a ATOM feed xml structure containing all previously set + fields. + + :returns: Tuple containing the feed root element and the element tree. + ''' + nsmap = dict() + if extensions: + for ext in self.__extensions.values() or []: + if ext.get('atom'): + nsmap.update(ext['inst'].extend_ns()) + + feed = etree.Element('feed', + xmlns='http://www.w3.org/2005/Atom', + nsmap=nsmap) + if self.__atom_feed_xml_lang: + feed.attrib['{http://www.w3.org/XML/1998/namespace}lang'] = \ + self.__atom_feed_xml_lang + + if not (self.__atom_id and self.__atom_title and self.__atom_updated): + missing = ([] if self.__atom_title else ['title']) + \ + ([] if self.__atom_id else ['id']) + \ + ([] if self.__atom_updated else ['updated']) + missing = ', '.join(missing) + raise ValueError('Required fields not set (%s)' % missing) + id = etree.SubElement(feed, 'id') + id.text = self.__atom_id + title = etree.SubElement(feed, 'title') + title.text = self.__atom_title + updated = etree.SubElement(feed, 'updated') + updated.text = self.__atom_updated.isoformat() + + # Add author elements + for a in self.__atom_author or []: + # Atom requires a name. Skip elements without. + if not a.get('name'): + continue + author = etree.SubElement(feed, 'author') + name = etree.SubElement(author, 'name') + name.text = a.get('name') + if a.get('email'): + email = etree.SubElement(author, 'email') + email.text = a.get('email') + if a.get('uri'): + uri = etree.SubElement(author, 'uri') + uri.text = a.get('uri') + + for l in self.__atom_link or []: + link = etree.SubElement(feed, 'link', href=l['href']) + if l.get('rel'): + link.attrib['rel'] = l['rel'] + if l.get('type'): + link.attrib['type'] = l['type'] + if l.get('hreflang'): + link.attrib['hreflang'] = l['hreflang'] + if l.get('title'): + link.attrib['title'] = l['title'] + if l.get('length'): + link.attrib['length'] = l['length'] + + for c in self.__atom_category or []: + cat = etree.SubElement(feed, 'category', term=c['term']) + if c.get('scheme'): + cat.attrib['scheme'] = c['scheme'] + if c.get('label'): + cat.attrib['label'] = c['label'] + + # Add author elements + for c in self.__atom_contributor or []: + # Atom requires a name. Skip elements without. + if not c.get('name'): + continue + contrib = etree.SubElement(feed, 'contributor') + name = etree.SubElement(contrib, 'name') + name.text = c.get('name') + if c.get('email'): + email = etree.SubElement(contrib, 'email') + email.text = c.get('email') + if c.get('uri'): + uri = etree.SubElement(contrib, 'uri') + uri.text = c.get('uri') + + if self.__atom_generator: + generator = etree.SubElement(feed, 'generator') + generator.text = self.__atom_generator['value'] + if self.__atom_generator.get('uri'): + generator.attrib['uri'] = self.__atom_generator['uri'] + if self.__atom_generator.get('version'): + generator.attrib['version'] = self.__atom_generator['version'] + + if self.__atom_icon: + icon = etree.SubElement(feed, 'icon') + icon.text = self.__atom_icon + + if self.__atom_logo: + logo = etree.SubElement(feed, 'logo') + logo.text = self.__atom_logo + + if self.__atom_rights: + rights = etree.SubElement(feed, 'rights') + rights.text = self.__atom_rights + + if self.__atom_subtitle: + subtitle = etree.SubElement(feed, 'subtitle') + subtitle.text = self.__atom_subtitle + + if extensions: + for ext in self.__extensions.values() or []: + if ext.get('atom'): + ext['inst'].extend_atom(feed) + + for entry in self.__feed_entries: + entry = entry.atom_entry() + feed.append(entry) + + doc = etree.ElementTree(feed) + return feed, doc + + def atom_str(self, pretty=False, extensions=True, encoding='UTF-8', + xml_declaration=True): + '''Generates an ATOM feed and returns the feed XML as string. + + :param pretty: If the feed should be split into multiple lines and + properly indented. + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :param encoding: Encoding used in the XML file (default: UTF-8). + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + :returns: String representation of the ATOM feed. + + **Return type:** The return type may vary between different Python + versions and your encoding parameters passed to this method. For + details have a look at the `lxml documentation + `_ + ''' + feed, doc = self._create_atom(extensions=extensions) + return etree.tostring(feed, pretty_print=pretty, encoding=encoding, + xml_declaration=xml_declaration) + + def atom_file(self, filename, extensions=True, pretty=False, + encoding='UTF-8', xml_declaration=True): + '''Generates an ATOM feed and write the resulting XML to a file. + + :param filename: Name of file to write or a file-like object or a URL. + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :param pretty: If the feed should be split into multiple lines and + properly indented. + :param encoding: Encoding used in the XML file (default: UTF-8). + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + ''' + feed, doc = self._create_atom(extensions=extensions) + doc.write(filename, pretty_print=pretty, encoding=encoding, + xml_declaration=xml_declaration) + + def _create_rss(self, extensions=True): + '''Create an RSS feed xml structure containing all previously set + fields. + + :returns: Tuple containing the feed root element and the element tree. + ''' + nsmap = dict() + if extensions: + for ext in self.__extensions.values() or []: + if ext.get('rss'): + nsmap.update(ext['inst'].extend_ns()) + + nsmap.update({'atom': 'http://www.w3.org/2005/Atom', + 'content': 'http://purl.org/rss/1.0/modules/content/'}) + + feed = etree.Element('rss', version='2.0', nsmap=nsmap) + channel = etree.SubElement(feed, 'channel') + if not (self.__rss_title and + self.__rss_link and + self.__rss_description): + missing = ([] if self.__rss_title else ['title']) + \ + ([] if self.__rss_link else ['link']) + \ + ([] if self.__rss_description else ['description']) + missing = ', '.join(missing) + raise ValueError('Required fields not set (%s)' % missing) + title = etree.SubElement(channel, 'title') + title.text = self.__rss_title + link = etree.SubElement(channel, 'link') + link.text = self.__rss_link + desc = etree.SubElement(channel, 'description') + desc.text = self.__rss_description + for ln in self.__atom_link or []: + # It is recommended to include a atom self link in rss documents… + if ln.get('rel') == 'self': + selflink = etree.SubElement( + channel, '{http://www.w3.org/2005/Atom}link', + href=ln['href'], rel='self') + if ln.get('type'): + selflink.attrib['type'] = ln['type'] + if ln.get('hreflang'): + selflink.attrib['hreflang'] = ln['hreflang'] + if ln.get('title'): + selflink.attrib['title'] = ln['title'] + if ln.get('length'): + selflink.attrib['length'] = ln['length'] + break + if self.__rss_category: + for cat in self.__rss_category: + category = etree.SubElement(channel, 'category') + category.text = cat['value'] + if cat.get('domain'): + category.attrib['domain'] = cat['domain'] + if self.__rss_cloud: + cloud = etree.SubElement(channel, 'cloud') + cloud.attrib['domain'] = self.__rss_cloud.get('domain') + cloud.attrib['port'] = self.__rss_cloud.get('port') + cloud.attrib['path'] = self.__rss_cloud.get('path') + cloud.attrib['registerProcedure'] = self.__rss_cloud.get( + 'registerProcedure') + cloud.attrib['protocol'] = self.__rss_cloud.get('protocol') + if self.__rss_copyright: + copyright = etree.SubElement(channel, 'copyright') + copyright.text = self.__rss_copyright + if self.__rss_docs: + docs = etree.SubElement(channel, 'docs') + docs.text = self.__rss_docs + if self.__rss_generator: + generator = etree.SubElement(channel, 'generator') + generator.text = self.__rss_generator + if self.__rss_image: + image = etree.SubElement(channel, 'image') + url = etree.SubElement(image, 'url') + url.text = self.__rss_image.get('url') + title = etree.SubElement(image, 'title') + title.text = self.__rss_image.get('title', self.__rss_title) + link = etree.SubElement(image, 'link') + link.text = self.__rss_image.get('link', self.__rss_link) + if self.__rss_image.get('width'): + width = etree.SubElement(image, 'width') + width.text = self.__rss_image.get('width') + if self.__rss_image.get('height'): + height = etree.SubElement(image, 'height') + height.text = self.__rss_image.get('height') + if self.__rss_image.get('description'): + description = etree.SubElement(image, 'description') + description.text = self.__rss_image.get('description') + if self.__rss_language: + language = etree.SubElement(channel, 'language') + language.text = self.__rss_language + if self.__rss_lastBuildDate: + lastBuildDate = etree.SubElement(channel, 'lastBuildDate') + + lastBuildDate.text = formatRFC2822(self.__rss_lastBuildDate) + if self.__rss_managingEditor: + managingEditor = etree.SubElement(channel, 'managingEditor') + managingEditor.text = self.__rss_managingEditor + if self.__rss_pubDate: + pubDate = etree.SubElement(channel, 'pubDate') + pubDate.text = formatRFC2822(self.__rss_pubDate) + if self.__rss_rating: + rating = etree.SubElement(channel, 'rating') + rating.text = self.__rss_rating + if self.__rss_skipHours: + skipHours = etree.SubElement(channel, 'skipHours') + for h in self.__rss_skipHours: + hour = etree.SubElement(skipHours, 'hour') + hour.text = str(h) + if self.__rss_skipDays: + skipDays = etree.SubElement(channel, 'skipDays') + for d in self.__rss_skipDays: + day = etree.SubElement(skipDays, 'day') + day.text = d + if self.__rss_textInput: + textInput = etree.SubElement(channel, 'textInput') + textInput.attrib['title'] = self.__rss_textInput.get('title') + textInput.attrib['description'] = \ + self.__rss_textInput.get('description') + textInput.attrib['name'] = self.__rss_textInput.get('name') + textInput.attrib['link'] = self.__rss_textInput.get('link') + if self.__rss_ttl: + ttl = etree.SubElement(channel, 'ttl') + ttl.text = str(self.__rss_ttl) + if self.__rss_webMaster: + webMaster = etree.SubElement(channel, 'webMaster') + webMaster.text = self.__rss_webMaster + + if extensions: + for ext in self.__extensions.values() or []: + if ext.get('rss'): + ext['inst'].extend_rss(feed) + + for entry in self.__feed_entries: + item = entry.rss_entry() + channel.append(item) + + doc = etree.ElementTree(feed) + return feed, doc + + def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', + xml_declaration=True): + '''Generates an RSS feed and returns the feed XML as string. + + :param pretty: If the feed should be split into multiple lines and + properly indented. + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :param encoding: Encoding used in the XML file (default: UTF-8). + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + :returns: String representation of the RSS feed. + + **Return type:** The return type may vary between different Python + versions and your encoding parameters passed to this method. For + details have a look at the `lxml documentation + `_ + ''' + feed, doc = self._create_rss(extensions=extensions) + return etree.tostring(feed, pretty_print=pretty, encoding=encoding, + xml_declaration=xml_declaration) + + def rss_file(self, filename, extensions=True, pretty=False, + encoding='UTF-8', xml_declaration=True): + '''Generates an RSS feed and write the resulting XML to a file. + + :param filename: Name of file to write or a file-like object or a URL. + :param extensions: Enable or disable the loaded extensions for the xml + generation (default: enabled). + :param pretty: If the feed should be split into multiple lines and + properly indented. + :param encoding: Encoding used in the XML file (default: UTF-8). + :param xml_declaration: If an XML declaration should be added to the + output (Default: enabled). + ''' + feed, doc = self._create_rss(extensions=extensions) + doc.write(filename, pretty_print=pretty, encoding=encoding, + xml_declaration=xml_declaration) + + def title(self, title=None): + '''Get or set the title value of the feed. It should contain a human + readable title for the feed. Often the same as the title of the + associated website. Title is mandatory for both ATOM and RSS and should + not be blank. + + :param title: The new title of the feed. + :returns: The feeds title. + ''' + if title is not None: + self.__atom_title = title + self.__rss_title = title + return self.__atom_title + + def id(self, id=None): + '''Get or set the feed id which identifies the feed using a universally + unique and permanent URI. If you have a long-term, renewable lease on + your Internet domain name, then you can feel free to use your website's + address. This field is for ATOM only. It is mandatory for ATOM. + + :param id: New Id of the ATOM feed. + :returns: Id of the feed. + ''' + + if id is not None: + self.__atom_id = id + return self.__atom_id + + def updated(self, updated=None): + '''Set or get the updated value which indicates the last time the feed + was modified in a significant way. + + The value can either be a string which will automatically be parsed or + a datetime.datetime object. In any case it is necessary that the value + include timezone information. + + This will set both atom:updated and rss:lastBuildDate. + + Default value + If not set, updated has as value the current date and time. + + :param updated: The modification date. + :returns: Modification date as datetime.datetime + ''' + if updated is not None: + if isinstance(updated, string_types): + updated = dateutil.parser.parse(updated) + if not isinstance(updated, datetime): + raise ValueError('Invalid datetime format') + if updated.tzinfo is None: + raise ValueError('Datetime object has no timezone info') + self.__atom_updated = updated + self.__rss_lastBuildDate = updated + + return self.__atom_updated + + def lastBuildDate(self, lastBuildDate=None): + '''Set or get the lastBuildDate value which indicates the last time the + content of the channel changed. + + The value can either be a string which will automatically be parsed or + a datetime.datetime object. In any case it is necessary that the value + include timezone information. + + This will set both atom:updated and rss:lastBuildDate. + + Default value + If not set, lastBuildDate has as value the current date and time. + + :param lastBuildDate: The modification date. + :returns: Modification date as datetime.datetime + ''' + return self.updated(lastBuildDate) + + def author(self, author=None, replace=False, **kwargs): + '''Get or set author data. An author element is a dictionary containing + a name, an email address and a URI. Name is mandatory for ATOM, email + is mandatory for RSS. + + This method can be called with: + + - the fields of an author as keyword arguments + - the fields of an author as a dictionary + - a list of dictionaries containing the author fields + + An author has the following fields: + + - *name* conveys a human-readable name for the person. + - *uri* contains a home page for the person. + - *email* contains an email address for the person. + + :param author: Dictionary or list of dictionaries with author data. + :param replace: Add or replace old data. + :returns: List of authors as dictionaries. + + Example:: + + >>> feedgen.author({'name':'John Doe', 'email':'jdoe@example.com'}) + [{'name':'John Doe','email':'jdoe@example.com'}] + + >>> feedgen.author([{'name':'Mr. X'},{'name':'Max'}]) + [{'name':'John Doe','email':'jdoe@example.com'}, + {'name':'John Doe'}, {'name':'Max'}] + + >>> feedgen.author(name='John Doe', email='jdoe@example.com', + replace=True) + [{'name':'John Doe','email':'jdoe@example.com'}] + + ''' + if author is None and kwargs: + author = kwargs + if author is not None: + if replace or self.__atom_author is None: + self.__atom_author = [] + self.__atom_author += ensure_format(author, + set(['name', 'email', 'uri']), + set(['name'])) + self.__rss_author = [] + for a in self.__atom_author: + if a.get('email'): + self.__rss_author.append(a['email']) + return self.__atom_author + + def link(self, link=None, replace=False, **kwargs): + '''Get or set link data. An link element is a dict with the fields + href, rel, type, hreflang, title, and length. Href is mandatory for + ATOM. + + This method can be called with: + + - the fields of a link as keyword arguments + - the fields of a link as a dictionary + - a list of dictionaries containing the link fields + + A link has the following fields: + + - *href* is the URI of the referenced resource (typically a Web page) + - *rel* contains a single link relationship type. It can be a full URI, + or one of the following predefined values (default=alternate): + + - *alternate* an alternate representation of the entry or feed, for + example a permalink to the html version of the entry, or the + front page of the weblog. + - *enclosure* a related resource which is potentially large in size + and might require special handling, for example an audio or video + recording. + - *related* an document related to the entry or feed. + - *self* the feed itself. + - *via* the source of the information provided in the entry. + + - *type* indicates the media type of the resource. + - *hreflang* indicates the language of the referenced resource. + - *title* human readable information about the link, typically for + display purposes. + - *length* the length of the resource, in bytes. + + RSS only supports one link with URL only. + + :param link: Dict or list of dicts with data. + :param replace: If old links are to be replaced (default: False) + :returns: Current set of link data + + Example:: + + >>> feedgen.link( href='http://example.com/', rel='self') + [{'href':'http://example.com/', 'rel':'self'}] + + ''' + if link is None and kwargs: + link = kwargs + if link is not None: + if replace or self.__atom_link is None: + self.__atom_link = [] + self.__atom_link += ensure_format( + link, + set(['href', 'rel', 'type', 'hreflang', 'title', 'length']), + set(['href']), + {'rel': [ + 'about', 'alternate', 'appendix', 'archives', 'author', + 'bookmark', 'canonical', 'chapter', 'collection', + 'contents', 'copyright', 'create-form', 'current', + 'derivedfrom', 'describedby', 'describes', 'disclosure', + 'duplicate', 'edit', 'edit-form', 'edit-media', + 'enclosure', 'first', 'glossary', 'help', 'hosts', 'hub', + 'icon', 'index', 'item', 'last', 'latest-version', + 'license', 'lrdd', 'memento', 'monitor', 'monitor-group', + 'next', 'next-archive', 'nofollow', 'noreferrer', + 'original', 'payment', 'predecessor-version', 'prefetch', + 'prev', 'preview', 'previous', 'prev-archive', + 'privacy-policy', 'profile', 'related', 'replies', + 'search', 'section', 'self', 'service', 'start', + 'stylesheet', 'subsection', 'successor-version', 'tag', + 'terms-of-service', 'timegate', 'timemap', 'type', 'up', + 'version-history', 'via', 'working-copy', 'working-copy-of' + ]}) + # RSS only needs one URL. We use the first link for RSS: + if len(self.__atom_link) > 0: + self.__rss_link = self.__atom_link[-1]['href'] + # return the set with more information (atom) + return self.__atom_link + + def category(self, category=None, replace=False, **kwargs): + '''Get or set categories that the feed belongs to. + + This method can be called with: + + - the fields of a category as keyword arguments + - the fields of a category as a dictionary + - a list of dictionaries containing the category fields + + A categories has the following fields: + + - *term* identifies the category + - *scheme* identifies the categorization scheme via a URI. + - *label* provides a human-readable label for display + + If a label is present it is used for the RSS feeds. Otherwise the term + is used. The scheme is used for the domain attribute in RSS. + + :param link: Dict or list of dicts with data. + :param replace: Add or replace old data. + :returns: List of category data. + ''' + if category is None and kwargs: + category = kwargs + if category is not None: + if replace or self.__atom_category is None: + self.__atom_category = [] + self.__atom_category += ensure_format( + category, + set(['term', 'scheme', 'label']), + set(['term'])) + # Map the ATOM categories to RSS categories. Use the atom:label as + # name or if not present the atom:term. The atom:scheme is the + # rss:domain. + self.__rss_category = [] + for cat in self.__atom_category: + rss_cat = {} + rss_cat['value'] = cat.get('label', cat['term']) + if cat.get('scheme'): + rss_cat['domain'] = cat['scheme'] + self.__rss_category.append(rss_cat) + return self.__atom_category + + def cloud(self, domain=None, port=None, path=None, registerProcedure=None, + protocol=None): + '''Set or get the cloud data of the feed. It is an RSS only attribute. + It specifies a web service that supports the rssCloud interface which + can be implemented in HTTP-POST, XML-RPC or SOAP 1.1. + + :param domain: The domain where the webservice can be found. + :param port: The port the webservice listens to. + :param path: The path of the webservice. + :param registerProcedure: The procedure to call. + :param protocol: Can be either HTTP-POST, XML-RPC or SOAP 1.1. + :returns: Dictionary containing the cloud data. + ''' + if domain is not None: + self.__rss_cloud = {'domain': domain, 'port': port, 'path': path, + 'registerProcedure': registerProcedure, + 'protocol': protocol} + return self.__rss_cloud + + def contributor(self, contributor=None, replace=False, **kwargs): + '''Get or set the contributor data of the feed. This is an ATOM only + value. + + This method can be called with: + - the fields of an contributor as keyword arguments + - the fields of an contributor as a dictionary + - a list of dictionaries containing the contributor fields + + An contributor has the following fields: + - *name* conveys a human-readable name for the person. + - *uri* contains a home page for the person. + - *email* contains an email address for the person. + + :param contributor: Dictionary or list of dictionaries with contributor + data. + :param replace: Add or replace old data. + :returns: List of contributors as dictionaries. + ''' + if contributor is None and kwargs: + contributor = kwargs + if contributor is not None: + if replace or self.__atom_contributor is None: + self.__atom_contributor = [] + self.__atom_contributor += ensure_format( + contributor, set(['name', 'email', 'uri']), set(['name'])) + return self.__atom_contributor + + def generator(self, generator=None, version=None, uri=None): + '''Get or the generator of the feed which identifies the software used + to generate the feed, for debugging and other purposes. Both the uri + and version attributes are optional and only available in the ATOM + feed. + + :param generator: Software used to create the feed. + :param version: Version of the software. + :param uri: URI the software can be found. + ''' + if generator is not None: + self.__atom_generator = {'value': generator} + if version is not None: + self.__atom_generator['version'] = version + if uri is not None: + self.__atom_generator['uri'] = uri + self.__rss_generator = generator + return self.__atom_generator + + def icon(self, icon=None): + '''Get or set the icon of the feed which is a small image which + provides iconic visual identification for the feed. Icons should be + square. This is an ATOM only value. + + :param icon: URI of the feeds icon. + :returns: URI of the feeds icon. + ''' + if icon is not None: + self.__atom_icon = icon + return self.__atom_icon + + def logo(self, logo=None): + '''Get or set the logo of the feed which is a larger image which + provides visual identification for the feed. Images should be twice as + wide as they are tall. This is an ATOM value but will also set the + rss:image value. + + :param logo: Logo of the feed. + :returns: Logo of the feed. + ''' + if logo is not None: + self.__atom_logo = logo + self.__rss_image = {'url': logo} + return self.__atom_logo + + def image(self, url=None, title=None, link=None, width=None, height=None, + description=None): + '''Set the image of the feed. This element is roughly equivalent to + atom:logo. + + :param url: The URL of a GIF, JPEG or PNG image. + :param title: Describes the image. The default value is the feeds + title. + :param link: URL of the site the image will link to. The default is to + use the feeds first altertate link. + :param width: Width of the image in pixel. The maximum is 144. + :param height: The height of the image. The maximum is 400. + :param description: Title of the link. + :returns: Data of the image as dictionary. + ''' + if url is not None: + self.__rss_image = {'url': url} + if title is not None: + self.__rss_image['title'] = title + if link is not None: + self.__rss_image['link'] = link + if width: + self.__rss_image['width'] = width + if height: + self.__rss_image['height'] = height + self.__atom_logo = url + return self.__rss_image + + def rights(self, rights=None): + '''Get or set the rights value of the feed which conveys information + about rights, e.g. copyrights, held in and over the feed. This ATOM + value will also set rss:copyright. + + :param rights: Rights information of the feed. + ''' + if rights is not None: + self.__atom_rights = rights + self.__rss_copyright = rights + return self.__atom_rights + + def copyright(self, copyright=None): + '''Get or set the copyright notice for content in the channel. This RSS + value will also set the atom:rights value. + + :param copyright: The copyright notice. + :returns: The copyright notice. + ''' + return self.rights(copyright) + + def subtitle(self, subtitle=None): + '''Get or set the subtitle value of the cannel which contains a + human-readable description or subtitle for the feed. This ATOM property + will also set the value for rss:description. + + :param subtitle: The subtitle of the feed. + :returns: The subtitle of the feed. + ''' + if subtitle is not None: + self.__atom_subtitle = subtitle + self.__rss_description = subtitle + return self.__atom_subtitle + + def description(self, description=None): + '''Set and get the description of the feed. This is an RSS only element + which is a phrase or sentence describing the channel. It is mandatory + for RSS feeds. It is roughly the same as atom:subtitle. Thus setting + this will also set atom:subtitle. + + :param description: Description of the channel. + :returns: Description of the channel. + + ''' + return self.subtitle(description) + + def docs(self, docs=None): + '''Get or set the docs value of the feed. This is an RSS only value. It + is a URL that points to the documentation for the format used in the + RSS file. It is probably a pointer to [1]. It is for people who might + stumble across an RSS file on a Web server 25 years from now and wonder + what it is. + + [1]: http://www.rssboard.org/rss-specification + + :param docs: URL of the format documentation. + :returns: URL of the format documentation. + ''' + if docs is not None: + self.__rss_docs = docs + return self.__rss_docs + + def language(self, language=None): + '''Get or set the language of the feed. It indicates the language the + channel is written in. This allows aggregators to group all Italian + language sites, for example, on a single page. This is an RSS only + field. However, this value will also be used to set the xml:lang + property of the ATOM feed node. + The value should be an IETF language tag. + + :param language: Language of the feed. + :returns: Language of the feed. + ''' + if language is not None: + self.__rss_language = language + self.__atom_feed_xml_lang = language + return self.__rss_language + + def managingEditor(self, managingEditor=None): + '''Set or get the value for managingEditor which is the email address + for person responsible for editorial content. This is a RSS only + value. + + :param managingEditor: Email adress of the managing editor. + :returns: Email adress of the managing editor. + ''' + if managingEditor is not None: + self.__rss_managingEditor = managingEditor + return self.__rss_managingEditor + + def pubDate(self, pubDate=None): + '''Set or get the publication date for the content in the channel. For + example, the New York Times publishes on a daily basis, the publication + date flips once every 24 hours. That's when the pubDate of the channel + changes. + + The value can either be a string which will automatically be parsed or + a datetime.datetime object. In any case it is necessary that the value + include timezone information. + + This will set both atom:updated and rss:lastBuildDate. + + :param pubDate: The publication date. + :returns: Publication date as datetime.datetime + ''' + if pubDate is not None: + if isinstance(pubDate, string_types): + pubDate = dateutil.parser.parse(pubDate) + if not isinstance(pubDate, datetime): + raise ValueError('Invalid datetime format') + if pubDate.tzinfo is None: + raise ValueError('Datetime object has no timezone info') + self.__rss_pubDate = pubDate + + return self.__rss_pubDate + + def rating(self, rating=None): + '''Set and get the PICS rating for the channel. It is an RSS only + value. + ''' + if rating is not None: + self.__rss_rating = rating + return self.__rss_rating + + def skipHours(self, hours=None, replace=False): + '''Set or get the value of skipHours, a hint for aggregators telling + them which hours they can skip. This is an RSS only value. + + This method can be called with an hour or a list of hours. The hours + are represented as integer values from 0 to 23. + + :param hours: List of hours the feedreaders should not check the feed. + :param replace: Add or replace old data. + :returns: List of hours the feedreaders should not check the feed. + ''' + if hours is not None: + if not (isinstance(hours, list) or isinstance(hours, set)): + hours = [hours] + for h in hours: + if h not in range(24): + raise ValueError('Invalid hour %s' % h) + if replace or not self.__rss_skipHours: + self.__rss_skipHours = set() + self.__rss_skipHours |= set(hours) + return self.__rss_skipHours + + def skipDays(self, days=None, replace=False): + '''Set or get the value of skipDays, a hint for aggregators telling + them which days they can skip This is an RSS only value. + + This method can be called with a day name or a list of day names. The + days are represented as strings from 'Monday' to 'Sunday'. + + :param hours: List of days the feedreaders should not check the feed. + :param replace: Add or replace old data. + :returns: List of days the feedreaders should not check the feed. + ''' + if days is not None: + if not (isinstance(days, list) or isinstance(days, set)): + days = [days] + for d in days: + if d not in ['Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday']: + raise ValueError('Invalid day %s' % d) + if replace or not self.__rss_skipDays: + self.__rss_skipDays = set() + self.__rss_skipDays |= set(days) + return self.__rss_skipDays + + def textInput(self, title=None, description=None, name=None, link=None): + '''Get or set the value of textInput. This is an RSS only field. The + purpose of the element is something of a mystery. You can + use it to specify a search engine box. Or to allow a reader to provide + feedback. Most aggregators ignore it. + + :param title: The label of the Submit button in the text input area. + :param description: Explains the text input area. + :param name: The name of the text object in the text input area. + :param link: The URL of the CGI script that processes text input + requests. + :returns: Dictionary containing textInput values. + ''' + if title is not None: + self.__rss_textInput = {} + self.__rss_textInput['title'] = title + self.__rss_textInput['description'] = description + self.__rss_textInput['name'] = name + self.__rss_textInput['link'] = link + return self.__rss_textInput + + def ttl(self, ttl=None): + '''Get or set the ttl value. It is an RSS only element. ttl stands for + time to live. It's a number of minutes that indicates how long a + channel can be cached before refreshing from the source. + + :param ttl: Integer value indicating how long the channel may be + cached. + :returns: Time to live. + ''' + if ttl is not None: + self.__rss_ttl = int(ttl) + return self.__rss_ttl + + def webMaster(self, webMaster=None): + '''Get and set the value of webMaster, which represents the email + address for the person responsible for technical issues relating to the + feed. This is an RSS only value. + + :param webMaster: Email address of the webmaster. + :returns: Email address of the webmaster. + ''' + if webMaster is not None: + self.__rss_webMaster = webMaster + return self.__rss_webMaster + + def add_entry(self, feedEntry=None): + '''This method will add a new entry to the feed. If the feedEntry + argument is omittet a new Entry object is created automatically. This + is the prefered way to add new entries to a feed. + + :param feedEntry: FeedEntry object to add. + :returns: FeedEntry object created or passed to this function. + + Example:: + + ... + >>> entry = feedgen.add_entry() + >>> entry.title('First feed entry') + + ''' + if feedEntry is None: + feedEntry = FeedEntry() + + version = sys.version_info[0] + + if version == 2: + items = self.__extensions.iteritems() + else: + items = self.__extensions.items() + + # Try to load extensions: + for extname, ext in items: + try: + feedEntry.register_extension(extname, + ext['extension_class_entry'], + ext['atom'], + ext['rss']) + except ImportError: + pass + + self.__feed_entries.append(feedEntry) + return feedEntry + + def add_item(self, item=None): + '''This method will add a new item to the feed. If the item argument is + omittet a new FeedEntry object is created automatically. This is just + another name for add_entry(...) + ''' + return self.add_entry(item) + + def entry(self, entry=None, replace=False): + '''Get or set feed entries. Use the add_entry() method instead to + automatically create the FeedEntry objects. + + This method takes both a single FeedEntry object or a list of objects. + + :param entry: FeedEntry object or list of FeedEntry objects. + :returns: List ob all feed entries. + ''' + if entry is not None: + if not isinstance(entry, list): + entry = [entry] + if replace: + self.__feed_entries = [] + + version = sys.version_info[0] + + if version == 2: + items = self.__extensions.iteritems() + else: + items = self.__extensions.items() + + # Try to load extensions: + for e in entry: + for extname, ext in items: + try: + e.register_extension(extname, + ext['extension_class_entry'], + ext['atom'], ext['rss']) + except ImportError: + pass + + self.__feed_entries += entry + return self.__feed_entries + + def item(self, item=None, replace=False): + '''Get or set feed items. This is just another name for entry(...) + ''' + return self.entry(item, replace) + + def remove_entry(self, entry): + '''Remove a single entry from the feed. This method accepts both the + FeedEntry object to remove or the index of the entry as argument. + + :param entry: Entry or index of entry to remove. + ''' + if isinstance(entry, FeedEntry): + self.__feed_entries.remove(entry) + else: + self.__feed_entries.pop(entry) + + def remove_item(self, item): + '''Remove a single item from the feed. This is another name for + remove_entry. + ''' + self.remove_entry(item) + + def load_extension(self, name, atom=True, rss=True): + '''Load a specific extension by name. + + :param name: Name of the extension to load. + :param atom: If the extension should be used for ATOM feeds. + :param rss: If the extension should be used for RSS feeds. + ''' + # Check loaded extensions + if not isinstance(self.__extensions, dict): + self.__extensions = {} + if name in self.__extensions.keys(): + raise ImportError('Extension already loaded') + + # Load extension + extname = name[0].upper() + name[1:] + feedsupmod = __import__('feedgen.ext.%s' % name) + feedextmod = getattr(feedsupmod.ext, name) + try: + entrysupmod = __import__('feedgen.ext.%s_entry' % name) + entryextmod = getattr(entrysupmod.ext, name + '_entry') + except ImportError: + # Use FeedExtension module instead + entrysupmod = feedsupmod + entryextmod = feedextmod + feedext = getattr(feedextmod, extname + 'Extension') + try: + entryext = getattr(entryextmod, extname + 'EntryExtension') + except AttributeError: + entryext = None + self.register_extension(name, feedext, entryext, atom, rss) + + def register_extension(self, namespace, extension_class_feed=None, + extension_class_entry=None, atom=True, rss=True): + '''Registers an extension by class. + + :param namespace: namespace for the extension + :param extension_class_feed: Class of the feed extension to load. + :param extension_class_entry: Class of the entry extension to load + :param atom: If the extension should be used for ATOM feeds. + :param rss: If the extension should be used for RSS feeds. + ''' + # Check loaded extensions + # `load_extension` ignores the "Extension" suffix. + if not isinstance(self.__extensions, dict): + self.__extensions = {} + if namespace in self.__extensions.keys(): + raise ImportError('Extension already loaded') + + # Load extension + extinst = extension_class_feed() + setattr(self, namespace, extinst) + + # `load_extension` registry + self.__extensions[namespace] = { + 'inst': extinst, + 'extension_class_feed': extension_class_feed, + 'extension_class_entry': extension_class_entry, + 'atom': atom, + 'rss': rss + } + + # Try to load the extension for already existing entries: + for entry in self.__feed_entries: + try: + entry.register_extension(namespace, + extension_class_entry, + atom, + rss) + except ImportError: + pass diff --git a/feedgen/util.py b/feedgen/util.py index c7c9454..228f0ec 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -1,72 +1,74 @@ # -*- coding: utf-8 -*- ''' - feedgen.util - ~~~~~~~~~~~~ + feedgen.util + ~~~~~~~~~~~~ - This file contains helper functions for the feed generator module. + This file contains helper functions for the feed generator module. - :copyright: 2013, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :copyright: 2013, Lars Kiesow + :license: FreeBSD and LGPL, see license.* for more details. ''' -import sys, locale +import locale +import sys def ensure_format(val, allowed, required, allowed_values=None, defaults=None): - '''Takes a dictionary or a list of dictionaries and check if all keys are in - the set of allowed keys, if all required keys are present and if the values - of a specific key are ok. + '''Takes a dictionary or a list of dictionaries and check if all keys are in + the set of allowed keys, if all required keys are present and if the values + of a specific key are ok. - :param val: Dictionaries to check. - :param allowed: Set of allowed keys. - :param required: Set of required keys. - :param allowed_values: Dictionary with keys and sets of their allowed values. - :param defaults: Dictionary with default values. - :returns: List of checked dictionaries. - ''' - if not val: - return None - if allowed_values is None: - allowed_values = {} - if defaults is None: - defaults = {} - # Make shure that we have a list of dicts. Even if there is only one. - if not isinstance(val, list): - val = [val] - for elem in val: - if not isinstance(elem, dict): - raise ValueError('Invalid data (value is no dictionary)') - # Set default values + :param val: Dictionaries to check. + :param allowed: Set of allowed keys. + :param required: Set of required keys. + :param allowed_values: Dictionary with keys and sets of their allowed + values. + :param defaults: Dictionary with default values. + :returns: List of checked dictionaries. + ''' + if not val: + return None + if allowed_values is None: + allowed_values = {} + if defaults is None: + defaults = {} + # Make shure that we have a list of dicts. Even if there is only one. + if not isinstance(val, list): + val = [val] + for elem in val: + if not isinstance(elem, dict): + raise ValueError('Invalid data (value is no dictionary)') + # Set default values - version = sys.version_info[0] + version = sys.version_info[0] - if version == 2: - items = defaults.iteritems() - else: - items = defaults.items() + if version == 2: + items = defaults.iteritems() + else: + items = defaults.items() - for k,v in items: - elem[k] = elem.get(k, v) - if not set(elem.keys()) <= allowed: - raise ValueError('Data contains invalid keys') - if not set(elem.keys()) >= required: - raise ValueError('Data contains not all required keys') + for k, v in items: + elem[k] = elem.get(k, v) + if not set(elem.keys()) <= allowed: + raise ValueError('Data contains invalid keys') + if not set(elem.keys()) >= required: + raise ValueError('Data contains not all required keys') - if version == 2: - values = allowed_values.iteritems() - else: - values = allowed_values.items() + if version == 2: + values = allowed_values.iteritems() + else: + values = allowed_values.items() - for k,v in values: - if elem.get(k) and not elem[k] in v: - raise ValueError('Invalid value for %s' % k ) - return val + for k, v in values: + if elem.get(k) and not elem[k] in v: + raise ValueError('Invalid value for %s' % k) + return val def formatRFC2822(d): - '''Make sure the locale setting do not interfere with the time format. - ''' - l = locale.setlocale(locale.LC_ALL) - locale.setlocale(locale.LC_ALL, 'C') - d = d.strftime('%a, %d %b %Y %H:%M:%S %z') - locale.setlocale(locale.LC_ALL, l) - return d + '''Make sure the locale setting do not interfere with the time format. + ''' + l = locale.setlocale(locale.LC_ALL) + locale.setlocale(locale.LC_ALL, 'C') + d = d.strftime('%a, %d %b %Y %H:%M:%S %z') + locale.setlocale(locale.LC_ALL, l) + return d diff --git a/feedgen/version.py b/feedgen/version.py index 9924809..92ad6d6 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- ''' - feedgen.version - ~~~~~~~~~~~~~~~ + feedgen.version + ~~~~~~~~~~~~~~~ :copyright: 2013-2017, Lars Kiesow - :license: FreeBSD and LGPL, see license.* for more details. + :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -18,8 +18,8 @@ version_str = '.'.join([str(x) for x in version]) version_major = version[:1] version_minor = version[:2] -version_full = version +version_full = version version_major_str = '.'.join([str(x) for x in version_major]) version_minor_str = '.'.join([str(x) for x in version_minor]) -version_full_str = '.'.join([str(x) for x in version_full]) +version_full_str = '.'.join([str(x) for x in version_full]) diff --git a/setup.py b/setup.py index 60c92ca..7b7ae11 100755 --- a/setup.py +++ b/setup.py @@ -6,36 +6,36 @@ import feedgen.version packages = ['feedgen', 'feedgen/ext'] -setup( - name = 'feedgen', - packages = packages, - version = feedgen.version.version_full_str, - description = 'Feed Generator (ATOM, RSS, Podcasts)', - author = 'Lars Kiesow', - author_email = 'lkiesow@uos.de', - url = 'http://lkiesow.github.io/python-feedgen', - keywords = ['feed','ATOM','RSS','podcast'], - license = 'FreeBSD and LGPLv3+', - install_requires = ['lxml', 'python-dateutil'], - classifiers = [ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3', - 'Topic :: Communications', - 'Topic :: Internet', - 'Topic :: Text Processing', - 'Topic :: Text Processing :: Markup', - 'Topic :: Text Processing :: Markup :: XML' - ], - long_description = '''\ +setup(name='feedgen', + packages=packages, + version=feedgen.version.version_full_str, + description='Feed Generator (ATOM, RSS, Podcasts)', + author='Lars Kiesow', + author_email='lkiesow@uos.de', + url='http://lkiesow.github.io/python-feedgen', + keywords=['feed', 'ATOM', 'RSS', 'podcast'], + license='FreeBSD and LGPLv3+', + install_requires=['lxml', 'python-dateutil'], + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'License :: OSI Approved :: GNU Lesser General Public License v3 ' + + 'or later (LGPLv3+)', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', + 'Topic :: Communications', + 'Topic :: Internet', + 'Topic :: Text Processing', + 'Topic :: Text Processing :: Markup', + 'Topic :: Text Processing :: Markup :: XML' + ], + long_description='''\ Feedgenerator ============= @@ -46,5 +46,4 @@ Podcasts. It is licensed under the terms of both, the FreeBSD license and the LGPLv3+. Choose the one which is more convenient for you. For more details have a look at license.bsd and license.lgpl. -''' -) +''') diff --git a/tests/test_entry.py b/tests/test_entry.py index a2055f2..090968f 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -7,100 +7,99 @@ These are test cases for a basic entry. """ import unittest -from lxml import etree from feedgen.feed import FeedGenerator + class TestSequenceFunctions(unittest.TestCase): - def setUp(self): + def setUp(self): - fg = FeedGenerator() - self.feedId = 'http://example.com' - self.title = 'Some Testfeed' + fg = FeedGenerator() + self.feedId = 'http://example.com' + self.title = 'Some Testfeed' - fg.id(self.feedId) - fg.title(self.title) + fg.id(self.feedId) + fg.title(self.title) - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The First Episode') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('The First Episode') - #Use also the different name add_item - fe = fg.add_item() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Second Episode') + # Use also the different name add_item + fe = fg.add_item() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('The Second Episode') - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('The Third Episode') - self.fg = fg + self.fg = fg - def test_checkEntryNumbers(self): + def test_checkEntryNumbers(self): - fg = self.fg - assert len(fg.entry()) == 3 + fg = self.fg + assert len(fg.entry()) == 3 - def test_checkItemNumbers(self): + def test_checkItemNumbers(self): - fg = self.fg - assert len(fg.item()) == 3 + fg = self.fg + assert len(fg.item()) == 3 - def test_checkEntryContent(self): + def test_checkEntryContent(self): - fg = self.fg - assert len(fg.entry()) != None + fg = self.fg + assert fg.entry() - def test_removeEntryByIndex(self): - fg = FeedGenerator() - self.feedId = 'http://example.com' - self.title = 'Some Testfeed' + def test_removeEntryByIndex(self): + fg = FeedGenerator() + self.feedId = 'http://example.com' + self.title = 'Some Testfeed' - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') - assert len(fg.entry()) == 1 - fg.remove_entry(0) - assert len(fg.entry()) == 0 + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('The Third Episode') + assert len(fg.entry()) == 1 + fg.remove_entry(0) + assert len(fg.entry()) == 0 - def test_removeEntryByEntry(self): - fg = FeedGenerator() - self.feedId = 'http://example.com' - self.title = 'Some Testfeed' + def test_removeEntryByEntry(self): + fg = FeedGenerator() + self.feedId = 'http://example.com' + self.title = 'Some Testfeed' - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('The Third Episode') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('The Third Episode') - assert len(fg.entry()) == 1 - fg.remove_entry(fe) - assert len(fg.entry()) == 0 + assert len(fg.entry()) == 1 + fg.remove_entry(fe) + assert len(fg.entry()) == 0 - def test_categoryHasDomain(self): - fg = FeedGenerator() - fg.title('some title') - fg.link( href='http://www.dontcare.com', rel='alternate' ) - fg.description('description') - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654321/1') - fe.title('some title') - fe.category([ - {'term' : 'category', - 'scheme': 'http://www.somedomain.com/category', - 'label' : 'Category', - }]) + def test_categoryHasDomain(self): + fg = FeedGenerator() + fg.title('some title') + fg.link(href='http://www.dontcare.com', rel='alternate') + fg.description('description') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('some title') + fe.category([ + {'term': 'category', + 'scheme': 'http://www.somedomain.com/category', + 'label': 'Category', + }]) - result = fg.rss_str() - assert b'domain="http://www.somedomain.com/category"' in result - - def test_content_cdata_type(self): - fg = FeedGenerator() - fg.title('some title') - fg.id('http://lernfunk.de/media/654322/1') - fe = fg.add_entry() - fe.id('http://lernfunk.de/media/654322/1') - fe.title('some title') - fe.content('content', type='CDATA') - result = fg.atom_str() - assert b'' in result + result = fg.rss_str() + assert b'domain="http://www.somedomain.com/category"' in result + def test_content_cdata_type(self): + fg = FeedGenerator() + fg.title('some title') + fg.id('http://lernfunk.de/media/654322/1') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654322/1') + fe.title('some title') + fe.content('content', type='CDATA') + result = fg.atom_str() + assert b'' in result diff --git a/tests/test_extension.py b/tests/test_extension.py index b8e9991..5c0dedf 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -12,6 +12,8 @@ from lxml import etree class TestExtensionSyndication(unittest.TestCase): + SYN_NS = {'sy': 'http://purl.org/rss/1.0/modules/syndication/'} + def setUp(self): self.fg = FeedGenerator() self.fg.load_extension('syndication') @@ -20,14 +22,11 @@ class TestExtensionSyndication(unittest.TestCase): self.fg.description('description') def test_update_period(self): - for period_type in ('hourly', 'daily', 'weekly', - 'monthly', 'yearly'): + for period_type in ('hourly', 'daily', 'weekly', 'monthly', 'yearly'): self.fg.syndication.update_period(period_type) root = etree.fromstring(self.fg.rss_str()) a = root.xpath('/rss/channel/sy:UpdatePeriod', - namespaces={ - 'sy':'http://purl.org/rss/1.0/modules/syndication/' - }) + namespaces=self.SYN_NS) assert a[0].text == period_type def test_update_frequency(self): @@ -35,19 +34,14 @@ class TestExtensionSyndication(unittest.TestCase): self.fg.syndication.update_frequency(frequency) root = etree.fromstring(self.fg.rss_str()) a = root.xpath('/rss/channel/sy:UpdateFrequency', - namespaces={ - 'sy':'http://purl.org/rss/1.0/modules/syndication/' - }) + namespaces=self.SYN_NS) assert a[0].text == str(frequency) def test_update_base(self): base = '2000-01-01T12:00+00:00' self.fg.syndication.update_base(base) root = etree.fromstring(self.fg.rss_str()) - a = root.xpath('/rss/channel/sy:UpdateBase', - namespaces={ - 'sy':'http://purl.org/rss/1.0/modules/syndication/' - }) + a = root.xpath('/rss/channel/sy:UpdateBase', namespaces=self.SYN_NS) assert a[0].text == base @@ -61,17 +55,17 @@ class TestExtensionPodcast(unittest.TestCase): self.fg.description('description') def test_category_new(self): - self.fg.podcast.itunes_category([{'cat':'Technology', - 'sub':'Podcasting'}]) + self.fg.podcast.itunes_category([{'cat': 'Technology', + 'sub': 'Podcasting'}]) self.fg.podcast.itunes_explicit('no') self.fg.podcast.itunes_complete('no') self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') self.fg.podcast.itunes_owner('John Doe', 'john@example.com') - ns = {'itunes':'http://www.itunes.com/dtds/podcast-1.0.dtd'} + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} root = etree.fromstring(self.fg.rss_str()) cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', - namespaces=ns) + namespaces=ns) assert cat[0] == 'Technology' assert scat[0] == 'Podcasting' @@ -81,10 +75,10 @@ class TestExtensionPodcast(unittest.TestCase): self.fg.podcast.itunes_complete('no') self.fg.podcast.itunes_new_feed_url('http://example.com/new-feed.rss') self.fg.podcast.itunes_owner('John Doe', 'john@example.com') - ns = {'itunes':'http://www.itunes.com/dtds/podcast-1.0.dtd'} + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} root = etree.fromstring(self.fg.rss_str()) cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', - namespaces=ns) + namespaces=ns) assert cat[0] == 'Technology' assert scat[0] == 'Podcasting' diff --git a/tests/test_feed.py b/tests/test_feed.py index 8d18e3b..2771a07 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -12,288 +12,314 @@ from lxml import etree from feedgen.feed import FeedGenerator from feedgen.ext.dc import DcExtension, DcEntryExtension + class TestSequenceFunctions(unittest.TestCase): - def setUp(self): + def setUp(self): - fg = FeedGenerator() + fg = FeedGenerator() - self.nsAtom = "http://www.w3.org/2005/Atom" - self.nsRss = "http://purl.org/rss/1.0/modules/content/" + self.nsAtom = "http://www.w3.org/2005/Atom" + self.nsRss = "http://purl.org/rss/1.0/modules/content/" - self.feedId = 'http://lernfunk.de/media/654321' - self.title = 'Some Testfeed' + self.feedId = 'http://lernfunk.de/media/654321' + self.title = 'Some Testfeed' - self.authorName = 'John Doe' - self.authorMail = 'john@example.de' - self.author = {'name': self.authorName,'email': self.authorMail} + self.authorName = 'John Doe' + self.authorMail = 'john@example.de' + self.author = {'name': self.authorName, 'email': self.authorMail} - self.linkHref = 'http://example.com' - self.linkRel = 'alternate' + self.linkHref = 'http://example.com' + self.linkRel = 'alternate' - self.logo = 'http://ex.com/logo.jpg' - self.subtitle = 'This is a cool feed!' + self.logo = 'http://ex.com/logo.jpg' + self.subtitle = 'This is a cool feed!' - self.link2Href = 'http://larskiesow.de/test.atom' - self.link2Rel = 'self' + self.link2Href = 'http://larskiesow.de/test.atom' + self.link2Rel = 'self' - self.language = 'en' + self.language = 'en' - self.categoryTerm = 'This category term' - self.categoryScheme = 'This category scheme' - self.categoryLabel = 'This category label' + self.categoryTerm = 'This category term' + self.categoryScheme = 'This category scheme' + self.categoryLabel = 'This category label' - self.cloudDomain = 'example.com' - self.cloudPort = '4711' - self.cloudPath = '/ws/example' - self.cloudRegisterProcedure = 'registerProcedure' - self.cloudProtocol = 'SOAP 1.1' + self.cloudDomain = 'example.com' + self.cloudPort = '4711' + self.cloudPath = '/ws/example' + self.cloudRegisterProcedure = 'registerProcedure' + self.cloudProtocol = 'SOAP 1.1' - self.icon = "http://example.com/icon.png" - self.contributor = {'name':"Contributor Name", 'uri':"Contributor Uri", - 'email': 'Contributor email'} - self.copyright = "The copyright notice" - self.docs = 'http://www.rssboard.org/rss-specification' - self.managingEditor = 'mail@example.com' - self.rating = '(PICS-1.1 "http://www.classify.org/safesurf/" 1 r (SS~~000 1))' - self.skipDays = 'Tuesday' - self.skipHours = 23 + self.icon = "http://example.com/icon.png" + self.contributor = {'name': "Contributor Name", + 'uri': "Contributor Uri", + 'email': 'Contributor email'} + self.copyright = "The copyright notice" + self.docs = 'http://www.rssboard.org/rss-specification' + self.managingEditor = 'mail@example.com' + self.rating = '(PICS-1.1 "http://www.classify.org/safesurf/" ' + \ + '1 r (SS~~000 1))' + self.skipDays = 'Tuesday' + self.skipHours = 23 - self.textInputTitle = "Text input title" - self.textInputDescription = "Text input description" - self.textInputName = "Text input name" - self.textInputLink = "Text input link" + self.textInputTitle = "Text input title" + self.textInputDescription = "Text input description" + self.textInputName = "Text input name" + self.textInputLink = "Text input link" - self.ttl = 900 + self.ttl = 900 - self.webMaster = 'webmaster@example.com' + self.webMaster = 'webmaster@example.com' - fg.id(self.feedId) - fg.title(self.title) - fg.author(self.author) - fg.link( href=self.linkHref, rel=self.linkRel ) - fg.logo(self.logo) - fg.subtitle(self.subtitle) - fg.link( href=self.link2Href, rel=self.link2Rel ) - fg.language(self.language) - fg.cloud(domain=self.cloudDomain, port=self.cloudPort, - path=self.cloudPath, registerProcedure=self.cloudRegisterProcedure, - protocol=self.cloudProtocol) - fg.icon(self.icon) - fg.category(term=self.categoryTerm, scheme=self.categoryScheme, - label=self.categoryLabel) - fg.contributor(self.contributor) - fg.copyright(self.copyright) - fg.docs(docs=self.docs) - fg.managingEditor(self.managingEditor) - fg.rating(self.rating) - fg.skipDays(self.skipDays) - fg.skipHours(self.skipHours) - fg.textInput(title=self.textInputTitle, - description=self.textInputDescription, name=self.textInputName, - link=self.textInputLink) - fg.ttl(self.ttl) - fg.webMaster(self.webMaster) + fg.id(self.feedId) + fg.title(self.title) + fg.author(self.author) + fg.link(href=self.linkHref, rel=self.linkRel) + fg.logo(self.logo) + fg.subtitle(self.subtitle) + fg.link(href=self.link2Href, rel=self.link2Rel) + fg.language(self.language) + fg.cloud(domain=self.cloudDomain, port=self.cloudPort, + path=self.cloudPath, + registerProcedure=self.cloudRegisterProcedure, + protocol=self.cloudProtocol) + fg.icon(self.icon) + fg.category(term=self.categoryTerm, scheme=self.categoryScheme, + label=self.categoryLabel) + fg.contributor(self.contributor) + fg.copyright(self.copyright) + fg.docs(docs=self.docs) + fg.managingEditor(self.managingEditor) + fg.rating(self.rating) + fg.skipDays(self.skipDays) + fg.skipHours(self.skipHours) + fg.textInput(title=self.textInputTitle, + description=self.textInputDescription, + name=self.textInputName, link=self.textInputLink) + fg.ttl(self.ttl) + fg.webMaster(self.webMaster) - self.fg = fg + self.fg = fg + def test_baseFeed(self): + fg = self.fg - def test_baseFeed(self): - fg = self.fg + assert fg.id() == self.feedId + assert fg.title() == self.title - assert fg.id() == self.feedId - assert fg.title() == self.title + assert fg.author()[0]['name'] == self.authorName + assert fg.author()[0]['email'] == self.authorMail - assert fg.author()[0]['name'] == self.authorName - assert fg.author()[0]['email'] == self.authorMail + assert fg.link()[0]['href'] == self.linkHref + assert fg.link()[0]['rel'] == self.linkRel - assert fg.link()[0]['href'] == self.linkHref - assert fg.link()[0]['rel'] == self.linkRel + assert fg.logo() == self.logo + assert fg.subtitle() == self.subtitle - assert fg.logo() == self.logo - assert fg.subtitle() == self.subtitle + assert fg.link()[1]['href'] == self.link2Href + assert fg.link()[1]['rel'] == self.link2Rel - assert fg.link()[1]['href'] == self.link2Href - assert fg.link()[1]['rel'] == self.link2Rel + assert fg.language() == self.language - assert fg.language() == self.language + def test_atomFeedFile(self): + fg = self.fg + filename = 'tmp_Atomfeed.xml' + fg.atom_file(filename=filename, pretty=True, xml_declaration=False) - def test_atomFeedFile(self): - fg = self.fg - filename = 'tmp_Atomfeed.xml' - fg.atom_file(filename=filename, pretty=True, xml_declaration=False) + with open(filename, "r") as myfile: + atomString = myfile.read().replace('\n', '') - with open (filename, "r") as myfile: - atomString=myfile.read().replace('\n', '') + self.checkAtomString(atomString) - self.checkAtomString(atomString) + def test_atomFeedString(self): + fg = self.fg - def test_atomFeedString(self): - fg = self.fg + atomString = fg.atom_str(pretty=True, xml_declaration=False) + self.checkAtomString(atomString) - atomString = fg.atom_str(pretty=True, xml_declaration=False) - self.checkAtomString(atomString) + def test_rel_values_for_atom(self): + values_for_rel = [ + 'about', 'alternate', 'appendix', 'archives', 'author', 'bookmark', + 'canonical', 'chapter', 'collection', 'contents', 'copyright', + 'create-form', 'current', 'derivedfrom', 'describedby', + 'describes', 'disclosure', 'duplicate', 'edit', 'edit-form', + 'edit-media', 'enclosure', 'first', 'glossary', 'help', 'hosts', + 'hub', 'icon', 'index', 'item', 'last', 'latest-version', + 'license', 'lrdd', 'memento', 'monitor', 'monitor-group', 'next', + 'next-archive', 'nofollow', 'noreferrer', 'original', 'payment', + 'predecessor-version', 'prefetch', 'prev', 'preview', 'previous', + 'prev-archive', 'privacy-policy', 'profile', 'related', 'replies', + 'search', 'section', 'self', 'service', 'start', 'stylesheet', + 'subsection', 'successor-version', 'tag', 'terms-of-service', + 'timegate', 'timemap', 'type', 'up', 'version-history', 'via', + 'working-copy', 'working-copy-of'] + links = [{'href': '%s/%s' % (self.linkHref, + val.replace('-', '_')), 'rel': val} + for val in values_for_rel] + fg = self.fg + fg.link(links, replace=True) + atomString = fg.atom_str(pretty=True, xml_declaration=False) + feed = etree.fromstring(atomString) + nsAtom = self.nsAtom + feed_links = feed.findall("{%s}link" % nsAtom) + idx = 0 + assert len(links) == len(feed_links) + while idx < len(values_for_rel): + assert feed_links[idx].get('href') == links[idx]['href'] + assert feed_links[idx].get('rel') == links[idx]['rel'] + idx += 1 - def test_rel_values_for_atom(self): - values_for_rel = [ - 'about', 'alternate', 'appendix', 'archives', 'author', 'bookmark', - 'canonical', 'chapter', 'collection', 'contents', 'copyright', 'create-form', - 'current', 'derivedfrom', 'describedby', 'describes', 'disclosure', - 'duplicate', 'edit', 'edit-form', 'edit-media', 'enclosure', 'first', 'glossary', - 'help', 'hosts', 'hub', 'icon', 'index', 'item', 'last', 'latest-version', 'license', - 'lrdd', 'memento', 'monitor', 'monitor-group', 'next', 'next-archive', 'nofollow', - 'noreferrer', 'original', 'payment', 'predecessor-version', 'prefetch', 'prev', 'preview', - 'previous', 'prev-archive', 'privacy-policy', 'profile', 'related', 'replies', 'search', - 'section', 'self', 'service', 'start', 'stylesheet', 'subsection', 'successor-version', - 'tag', 'terms-of-service', 'timegate', 'timemap', 'type', 'up', 'version-history', 'via', - 'working-copy', 'working-copy-of' - ] - links = [{'href': '%s/%s' % (self.linkHref, val.replace('-', '_')), 'rel': val} for val in values_for_rel] - fg = self.fg - fg.link(links, replace=True) - atomString = fg.atom_str(pretty=True, xml_declaration=False) - feed = etree.fromstring(atomString) - nsAtom = self.nsAtom - feed_links = feed.findall("{%s}link" % nsAtom) - idx = 0 - assert len(links) == len(feed_links) - while idx < len(values_for_rel): - assert feed_links[idx].get('href') == links[idx]['href'] - assert feed_links[idx].get('rel') == links[idx]['rel'] - idx += 1 + def test_rel_values_for_rss(self): + values_for_rel = [ + 'about', 'alternate', 'appendix', 'archives', 'author', 'bookmark', + 'canonical', 'chapter', 'collection', 'contents', 'copyright', + 'create-form', 'current', 'derivedfrom', 'describedby', + 'describes', 'disclosure', 'duplicate', 'edit', 'edit-form', + 'edit-media', 'enclosure', 'first', 'glossary', 'help', 'hosts', + 'hub', 'icon', 'index', 'item', 'last', 'latest-version', + 'license', 'lrdd', 'memento', 'monitor', 'monitor-group', 'next', + 'next-archive', 'nofollow', 'noreferrer', 'original', 'payment', + 'predecessor-version', 'prefetch', 'prev', 'preview', 'previous', + 'prev-archive', 'privacy-policy', 'profile', 'related', 'replies', + 'search', 'section', 'self', 'service', 'start', 'stylesheet', + 'subsection', 'successor-version', 'tag', 'terms-of-service', + 'timegate', 'timemap', 'type', 'up', 'version-history', 'via', + 'working-copy', 'working-copy-of'] + links = [{'href': '%s/%s' % (self.linkHref, + val.replace('-', '_')), 'rel': val} + for val in values_for_rel] + fg = self.fg + fg.link(links, replace=True) + rssString = fg.rss_str(pretty=True, xml_declaration=False) + feed = etree.fromstring(rssString) + channel = feed.find("channel") + nsAtom = self.nsAtom - def test_rel_values_for_rss(self): - values_for_rel = [ - 'about', 'alternate', 'appendix', 'archives', 'author', 'bookmark', - 'canonical', 'chapter', 'collection', 'contents', 'copyright', 'create-form', - 'current', 'derivedfrom', 'describedby', 'describes', 'disclosure', - 'duplicate', 'edit', 'edit-form', 'edit-media', 'enclosure', 'first', 'glossary', - 'help', 'hosts', 'hub', 'icon', 'index', 'item', 'last', 'latest-version', 'license', - 'lrdd', 'memento', 'monitor', 'monitor-group', 'next', 'next-archive', 'nofollow', - 'noreferrer', 'original', 'payment', 'predecessor-version', 'prefetch', 'prev', 'preview', - 'previous', 'prev-archive', 'privacy-policy', 'profile', 'related', 'replies', 'search', - 'section', 'self', 'service', 'start', 'stylesheet', 'subsection', 'successor-version', - 'tag', 'terms-of-service', 'timegate', 'timemap', 'type', 'up', 'version-history', 'via', - 'working-copy', 'working-copy-of' - ] - links = [{'href': '%s/%s' % (self.linkHref, val.replace('-', '_')), 'rel': val} for val in values_for_rel] - fg = self.fg - fg.link(links, replace=True) - rssString = fg.rss_str(pretty=True, xml_declaration=False) - feed = etree.fromstring(rssString) - channel = feed.find("channel") - nsAtom = self.nsAtom + atom_links = channel.findall("{%s}link" % nsAtom) + # rss feed only implements atom's 'self' link + assert len(atom_links) == 1 + assert atom_links[0].get('href') == '%s/%s' % (self.linkHref, 'self') + assert atom_links[0].get('rel') == 'self' - atom_links = channel.findall("{%s}link" % nsAtom) - assert len(atom_links) == 1 # rss feed only implements atom's 'self' link - assert atom_links[0].get('href') == '%s/%s' % (self.linkHref, 'self') - assert atom_links[0].get('rel') == 'self' + rss_links = channel.findall('link') + # RSS only needs one URL. We use the first link for RSS: + assert len(rss_links) == 1 + assert rss_links[0].text == '%s/%s' % \ + (self.linkHref, 'working-copy-of'.replace('-', '_')) - rss_links = channel.findall('link') - assert len(rss_links) == 1 # RSS only needs one URL. We use the first link for RSS: - assert rss_links[0].text == '%s/%s' % (self.linkHref, 'working-copy-of'.replace('-', '_')) + def checkAtomString(self, atomString): - def checkAtomString(self, atomString): + feed = etree.fromstring(atomString) - feed = etree.fromstring(atomString) + nsAtom = self.nsAtom - nsAtom = self.nsAtom + assert feed.find("{%s}title" % nsAtom).text == self.title + assert feed.find("{%s}updated" % nsAtom).text is not None + assert feed.find("{%s}id" % nsAtom).text == self.feedId + assert feed.find("{%s}category" % nsAtom)\ + .get('term') == self.categoryTerm + assert feed.find("{%s}category" % nsAtom)\ + .get('label') == self.categoryLabel + assert feed.find("{%s}author" % nsAtom)\ + .find("{%s}name" % nsAtom).text == self.authorName + assert feed.find("{%s}author" % nsAtom)\ + .find("{%s}email" % nsAtom).text == self.authorMail + assert feed.findall("{%s}link" % nsAtom)[0]\ + .get('href') == self.linkHref + assert feed.findall("{%s}link" % nsAtom)[0].get('rel') == self.linkRel + assert feed.findall("{%s}link" % nsAtom)[1]\ + .get('href') == self.link2Href + assert feed.findall("{%s}link" % nsAtom)[1].get('rel') == self.link2Rel + assert feed.find("{%s}logo" % nsAtom).text == self.logo + assert feed.find("{%s}icon" % nsAtom).text == self.icon + assert feed.find("{%s}subtitle" % nsAtom).text == self.subtitle + assert feed.find("{%s}contributor" % nsAtom)\ + .find("{%s}name" % nsAtom).text == self.contributor['name'] + assert feed.find("{%s}contributor" % nsAtom)\ + .find("{%s}email" % nsAtom).text == self.contributor['email'] + assert feed.find("{%s}contributor" % nsAtom)\ + .find("{%s}uri" % nsAtom).text == self.contributor['uri'] + assert feed.find("{%s}rights" % nsAtom).text == self.copyright - assert feed.find("{%s}title" % nsAtom).text == self.title - assert feed.find("{%s}updated" % nsAtom).text != None - assert feed.find("{%s}id" % nsAtom).text == self.feedId - assert feed.find("{%s}category" % nsAtom).get('term') == self.categoryTerm - assert feed.find("{%s}category" % nsAtom).get('label') == self.categoryLabel - assert feed.find("{%s}author" % nsAtom).find("{%s}name" % nsAtom).text == self.authorName - assert feed.find("{%s}author" % nsAtom).find("{%s}email" % nsAtom).text == self.authorMail - assert feed.findall("{%s}link" % nsAtom)[0].get('href') == self.linkHref - assert feed.findall("{%s}link" % nsAtom)[0].get('rel') == self.linkRel - assert feed.findall("{%s}link" % nsAtom)[1].get('href') == self.link2Href - assert feed.findall("{%s}link" % nsAtom)[1].get('rel') == self.link2Rel - assert feed.find("{%s}logo" % nsAtom).text == self.logo - assert feed.find("{%s}icon" % nsAtom).text == self.icon - assert feed.find("{%s}subtitle" % nsAtom).text == self.subtitle - assert feed.find("{%s}contributor" % nsAtom).find("{%s}name" % nsAtom).text == self.contributor['name'] - assert feed.find("{%s}contributor" % nsAtom).find("{%s}email" % nsAtom).text == self.contributor['email'] - assert feed.find("{%s}contributor" % nsAtom).find("{%s}uri" % nsAtom).text == self.contributor['uri'] - assert feed.find("{%s}rights" % nsAtom).text == self.copyright + def test_rssFeedFile(self): + fg = self.fg + filename = 'tmp_Rssfeed.xml' + fg.rss_file(filename=filename, pretty=True, xml_declaration=False) - def test_rssFeedFile(self): - fg = self.fg - filename = 'tmp_Rssfeed.xml' - fg.rss_file(filename=filename, pretty=True, xml_declaration=False) + with open(filename, "r") as myfile: + rssString = myfile.read().replace('\n', '') - with open (filename, "r") as myfile: - rssString=myfile.read().replace('\n', '') + self.checkRssString(rssString) - self.checkRssString(rssString) + def test_rssFeedString(self): + fg = self.fg + rssString = fg.rss_str(pretty=True, xml_declaration=False) + self.checkRssString(rssString) - def test_rssFeedString(self): - fg = self.fg - rssString = fg.rss_str(pretty=True, xml_declaration=False) - self.checkRssString(rssString) + def test_loadPodcastExtension(self): + fg = self.fg + fg.add_entry() + fg.load_extension('podcast', atom=True, rss=True) + fg.add_entry() - def test_loadPodcastExtension(self): - fg = self.fg - fg.add_entry() - fg.load_extension('podcast', atom=True, rss=True) - fg.add_entry() + def test_loadDcExtension(self): + fg = self.fg + fg.add_entry() + fg.load_extension('dc', atom=True, rss=True) + fg.add_entry() - def test_loadDcExtension(self): - fg = self.fg - fg.add_entry() - fg.load_extension('dc', atom=True, rss=True) - fg.add_entry() + def test_extensionAlreadyLoaded(self): + fg = self.fg + fg.load_extension('dc', atom=True, rss=True) + with self.assertRaises(ImportError): + fg.load_extension('dc') - def test_extensionAlreadyLoaded(self): - fg = self.fg - fg.load_extension('dc', atom=True, rss=True) - with self.assertRaises(ImportError) as context: - fg.load_extension('dc') + def test_registerCustomExtension(self): + fg = self.fg + fg.add_entry() + fg.register_extension('dc', DcExtension, DcEntryExtension) + fg.add_entry() - def test_registerCustomExtension(self): - fg = self.fg - fg.add_entry() - fg.register_extension('dc', DcExtension, DcEntryExtension) - fg.add_entry() + def checkRssString(self, rssString): - def checkRssString(self, rssString): + feed = etree.fromstring(rssString) + nsAtom = self.nsAtom - feed = etree.fromstring(rssString) - nsAtom = self.nsAtom - nsRss = self.nsRss + ch = feed.find("channel") + assert ch is not None - channel = feed.find("channel") - assert channel != None + assert ch.find("title").text == self.title + assert ch.find("description").text == self.subtitle + assert ch.find("lastBuildDate").text is not None + docs = "http://www.rssboard.org/rss-specification" + assert ch.find("docs").text == docs + assert ch.find("generator").text == "python-feedgen" + assert ch.findall("{%s}link" % nsAtom)[0].get('href') == self.link2Href + assert ch.findall("{%s}link" % nsAtom)[0].get('rel') == self.link2Rel + assert ch.find("image").find("url").text == self.logo + assert ch.find("image").find("title").text == self.title + assert ch.find("image").find("link").text == self.link2Href + assert ch.find("category").text == self.categoryLabel + assert ch.find("cloud").get('domain') == self.cloudDomain + assert ch.find("cloud").get('port') == self.cloudPort + assert ch.find("cloud").get('path') == self.cloudPath + assert ch.find("cloud").get('registerProcedure') == \ + self.cloudRegisterProcedure + assert ch.find("cloud").get('protocol') == self.cloudProtocol + assert ch.find("copyright").text == self.copyright + assert ch.find("docs").text == self.docs + assert ch.find("managingEditor").text == self.managingEditor + assert ch.find("rating").text == self.rating + assert ch.find("skipDays").find("day").text == self.skipDays + assert int(ch.find("skipHours").find("hour").text) == self.skipHours + assert ch.find("textInput").get('title') == self.textInputTitle + assert ch.find("textInput").get('description') == \ + self.textInputDescription + assert ch.find("textInput").get('name') == self.textInputName + assert ch.find("textInput").get('link') == self.textInputLink + assert int(ch.find("ttl").text) == self.ttl + assert ch.find("webMaster").text == self.webMaster - assert channel.find("title").text == self.title - assert channel.find("description").text == self.subtitle - assert channel.find("lastBuildDate").text != None - assert channel.find("docs").text == "http://www.rssboard.org/rss-specification" - assert channel.find("generator").text == "python-feedgen" - assert channel.findall("{%s}link" % nsAtom)[0].get('href') == self.link2Href - assert channel.findall("{%s}link" % nsAtom)[0].get('rel') == self.link2Rel - assert channel.find("image").find("url").text == self.logo - assert channel.find("image").find("title").text == self.title - assert channel.find("image").find("link").text == self.link2Href - assert channel.find("category").text == self.categoryLabel - assert channel.find("cloud").get('domain') == self.cloudDomain - assert channel.find("cloud").get('port') == self.cloudPort - assert channel.find("cloud").get('path') == self.cloudPath - assert channel.find("cloud").get('registerProcedure') == self.cloudRegisterProcedure - assert channel.find("cloud").get('protocol') == self.cloudProtocol - assert channel.find("copyright").text == self.copyright - assert channel.find("docs").text == self.docs - assert channel.find("managingEditor").text == self.managingEditor - assert channel.find("rating").text == self.rating - assert channel.find("skipDays").find("day").text == self.skipDays - assert int(channel.find("skipHours").find("hour").text) == self.skipHours - assert channel.find("textInput").get('title') == self.textInputTitle - assert channel.find("textInput").get('description') == self.textInputDescription - assert channel.find("textInput").get('name') == self.textInputName - assert channel.find("textInput").get('link') == self.textInputLink - assert int(channel.find("ttl").text) == self.ttl - assert channel.find("webMaster").text == self.webMaster if __name__ == '__main__': - unittest.main() + unittest.main()