From 9f4431880e680e30c94446f0c2e4fc5213df19b7 Mon Sep 17 00:00:00 2001 From: Zhiming Wang Date: Mon, 19 Dec 2016 00:41:01 -0500 Subject: [PATCH 01/96] Rename url to uri in Atom feeds According to RFC 4287, there is no atom:url element, only atom:uri, so replace occurrences of atom:url with atom:uri for compliance. Also rename variables holding atom:uri from email to uri to better indicate what they actually are. --- feedgen/entry.py | 8 ++++---- feedgen/feed.py | 10 +++++----- tests/test_feed.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index d1286c1..ef5dee3 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -90,8 +90,8 @@ def atom_entry(self, extensions=True): email = etree.SubElement(author, 'email') email.text = a.get('email') if a.get('uri'): - email = etree.SubElement(author, 'url') - email.text = a.get('uri') + uri = etree.SubElement(author, 'uri') + uri.text = a.get('uri') if self.__atom_content: content = etree.SubElement(entry, 'content') @@ -156,8 +156,8 @@ def atom_entry(self, extensions=True): email = etree.SubElement(contrib, 'email') email.text = c.get('email') if c.get('uri'): - email = etree.SubElement(contrib, 'url') - email.text = c.get('uri') + uri = etree.SubElement(contrib, 'uri') + uri.text = c.get('uri') if self.__atom_published: published = etree.SubElement(entry, 'published') diff --git a/feedgen/feed.py b/feedgen/feed.py index 18b5b18..58f1847 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -47,7 +47,7 @@ def __init__(self): self.__atom_contributor = None self.__atom_generator = { 'value' :'python-feedgen', - 'url' :'http://lkiesow.github.io/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 @@ -124,8 +124,8 @@ def _create_atom(self, extensions=True): email = etree.SubElement(author, 'email') email.text = a.get('email') if a.get('uri'): - email = etree.SubElement(author, 'url') - email.text = 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']) @@ -159,8 +159,8 @@ def _create_atom(self, extensions=True): email = etree.SubElement(contrib, 'email') email.text = c.get('email') if c.get('uri'): - email = etree.SubElement(contrib, 'url') - email.text = c.get('uri') + uri = etree.SubElement(contrib, 'uri') + uri.text = c.get('uri') if self.__atom_generator: generator = etree.SubElement(feed, 'generator') diff --git a/tests/test_feed.py b/tests/test_feed.py index 7c70760..8d18e3b 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -215,7 +215,7 @@ def checkAtomString(self, atomString): 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}url" % nsAtom).text == self.contributor['uri'] + 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): From ccf18502bc2c4aad2c04e78b3715aa4314ec04e0 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 5 Jan 2017 00:35:06 +0100 Subject: [PATCH 02/96] Release 0.4.1 - Fixed URI attributes in Atom feeds - Fixed TOC generation in docs Signed-off-by: Lars Kiesow --- feedgen/version.py | 4 ++-- python-feedgen.spec | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 8bb558f..9924809 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -3,14 +3,14 @@ feedgen.version ~~~~~~~~~~~~~~~ - :copyright: 2013-2015, Lars Kiesow + :copyright: 2013-2017, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' 'Version of python-feedgen represented as tuple' -version = (0, 4, 0) +version = (0, 4, 1) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 9d4181a..58b3cfe 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.4.0 +Version: 0.4.1 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -93,6 +93,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* Thu Jan 05 2017 Lars Kiesow - 0.4.1-1 +- Update to 0.4.1 + * Sun Sep 04 2016 Lars Kiesow - 0.4.0-1 - Update to 0.4.0 From 444855a24851a3dd4fce9e477b18c164ca1de257 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Wed, 21 Dec 2016 02:04:24 +0100 Subject: [PATCH 03/96] Flake8 Compatibility This patch makes the feedgen flake8 compatible, fixing some minor issues along the way. Most noticeable, this switches from tabs to spaces. Signed-off-by: Lars Kiesow --- Makefile | 1 + doc/conf.py | 94 +- feedgen/__init__.py | 201 +-- feedgen/__main__.py | 243 ++-- feedgen/compat.py | 4 +- feedgen/entry.py | 1325 ++++++++++--------- feedgen/ext/__init__.py | 6 +- feedgen/ext/base.py | 54 +- feedgen/ext/dc.py | 793 ++++++------ feedgen/ext/podcast.py | 690 +++++----- feedgen/ext/podcast_entry.py | 463 +++---- feedgen/ext/torrent.py | 223 ++-- feedgen/feed.py | 2310 +++++++++++++++++----------------- feedgen/util.py | 112 +- feedgen/version.py | 10 +- setup.py | 63 +- tests/test_entry.py | 185 ++- tests/test_extension.py | 30 +- tests/test_feed.py | 586 ++++----- 19 files changed, 3687 insertions(+), 3706 deletions(-) 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 @@ 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 @@ # 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 @@ #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 @@ 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_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 @@ 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 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 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`. - --------------------- - 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() - - 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' ) - - 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)) - - 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.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('atom'): - fg.atom_file(arg) - - elif arg.endswith('rss'): - fg.rss_file(arg) + 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] + + 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://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('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.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('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 @@ 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. - - :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. - - :param item: A RSS item element. - :returns: The item element. - ''' - self._extend_xml(item) - return item + '''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 + + 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 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 @@ 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 @@ class PodcastEntryExtension(BaseEntryExtension): - '''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 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' - - if self.__itunes_author: - author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) - author.text = self.__itunes_author - - 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_image: - image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) - image.attrib['href'] = self.__itunes_image - - if self.__itunes_duration: - duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) - duration.text = self.__itunes_duration - - if self.__itunes_explicit in ('yes', 'no', 'clean'): - explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) - explicit.text = self.__itunes_explicit - - 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 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_subtitle: - subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) - subtitle.text = self.__itunes_subtitle - - if self.__itunes_summary: - summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS) - summary.text = self.__itunes_summary - return entry - - - 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_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 episodes - from appearing in the iTunes podcast directory. - - :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 - - - 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. - - 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_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. - - 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 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_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". - - 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 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_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”. - - :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 + '''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 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' + + if self.__itunes_author: + author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) + author.text = self.__itunes_author + + 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_image: + image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) + image.attrib['href'] = self.__itunes_image + + if self.__itunes_duration: + duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) + duration.text = self.__itunes_duration + + if self.__itunes_explicit in ('yes', 'no', 'clean'): + explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) + explicit.text = self.__itunes_explicit + + 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_order is not None and self.__itunes_order >= 0: + order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) + order.text = str(self.__itunes_order) + + if self.__itunes_subtitle: + subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) + subtitle.text = self.__itunes_subtitle + + if self.__itunes_summary: + summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS) + summary.text = self.__itunes_summary + return entry + + 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_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. + + :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 + + 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. + + 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_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. + + 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 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 + + 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". + + 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_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”. + + :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 + + 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 itunes_order is not 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 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 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 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 - - - 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 - - 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_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_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. - - :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. - - :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. - - :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. - - :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 - - :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. - - :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 + '''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. + + :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_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_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 + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 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 @@ 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_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 @@ 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 @@ 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 @@ """ import unittest -from lxml import etree from feedgen.feed import FeedGenerator -class TestSequenceFunctions(unittest.TestCase): - def setUp(self): - - fg = FeedGenerator() - self.feedId = 'http://example.com' - self.title = 'Some Testfeed' - - 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') - - #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') - - self.fg = fg - - def test_checkEntryNumbers(self): - - fg = self.fg - assert len(fg.entry()) == 3 - - def test_checkItemNumbers(self): - - fg = self.fg - assert len(fg.item()) == 3 - - def test_checkEntryContent(self): - - fg = self.fg - assert len(fg.entry()) != None - - 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 - - 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') - - 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', - }]) - - 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 +class TestSequenceFunctions(unittest.TestCase): + def setUp(self): + + fg = FeedGenerator() + self.feedId = 'http://example.com' + self.title = 'Some Testfeed' + + 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') + + # 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') + + self.fg = fg + + def test_checkEntryNumbers(self): + + fg = self.fg + assert len(fg.entry()) == 3 + + def test_checkItemNumbers(self): + + fg = self.fg + assert len(fg.item()) == 3 + + def test_checkEntryContent(self): + + fg = self.fg + assert fg.entry() + + 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 + + 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') + + 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', + }]) + + 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 @@ 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 @@ def setUp(self): 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 @@ def test_update_frequency(self): 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 @@ def setUp(self): 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 @@ def test_category(self): 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 feedgen.feed import FeedGenerator from feedgen.ext.dc import DcExtension, DcEntryExtension -class TestSequenceFunctions(unittest.TestCase): - - def setUp(self): - - fg = FeedGenerator() - - 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.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.logo = 'http://ex.com/logo.jpg' - self.subtitle = 'This is a cool feed!' - - self.link2Href = 'http://larskiesow.de/test.atom' - self.link2Rel = 'self' +class TestSequenceFunctions(unittest.TestCase): - self.language = 'en' + def setUp(self): + + fg = FeedGenerator() + + 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.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.logo = 'http://ex.com/logo.jpg' + self.subtitle = 'This is a cool feed!' + + self.link2Href = 'http://larskiesow.de/test.atom' + self.link2Rel = 'self' + + self.language = 'en' + + 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.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.ttl = 900 + + 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) + + self.fg = fg + + def test_baseFeed(self): + fg = self.fg + + 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.link()[0]['href'] == self.linkHref + assert fg.link()[0]['rel'] == self.linkRel + + 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.language() == self.language + + 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', '') + + self.checkAtomString(atomString) + + def test_atomFeedString(self): + fg = self.fg + + 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_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' + + 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('-', '_')) + + def checkAtomString(self, atomString): + + feed = etree.fromstring(atomString) + + 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 + + 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', '') + + 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_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_registerCustomExtension(self): + fg = self.fg + fg.add_entry() + fg.register_extension('dc', DcExtension, DcEntryExtension) + fg.add_entry() + + def checkRssString(self, rssString): + + feed = etree.fromstring(rssString) + nsAtom = self.nsAtom + + ch = feed.find("channel") + assert ch is not 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 - 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.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.ttl = 900 - - 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) - - self.fg = fg - - - def test_baseFeed(self): - fg = self.fg - - 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.link()[0]['href'] == self.linkHref - assert fg.link()[0]['rel'] == self.linkRel - - 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.language() == self.language - - 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', '') - - self.checkAtomString(atomString) - - def test_atomFeedString(self): - fg = self.fg - - 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_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) - 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') - 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): - - feed = etree.fromstring(atomString) - - nsAtom = self.nsAtom - - 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) - - with open (filename, "r") as myfile: - rssString=myfile.read().replace('\n', '') - - 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_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) 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 checkRssString(self, rssString): - - feed = etree.fromstring(rssString) - nsAtom = self.nsAtom - nsRss = self.nsRss - - channel = feed.find("channel") - assert channel != None - - 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() From ed3ebb4db635778c8e54192eec14ff67265415db Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 22 Dec 2016 00:05:55 +0100 Subject: [PATCH 04/96] Readme as reStructuredText Sphinx always included the readme file into the generated documentation. Since Markdown and reStructuredText are not fully compatible, the output was now partly broken. This moves to reStructuredText only to avoid that problem. Signed-off-by: Lars Kiesow --- doc/conf.py | 2 +- readme.md => readme.rst | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) rename readme.md => readme.rst (93%) diff --git a/doc/conf.py b/doc/conf.py index 5495c4a..4df01a6 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -260,7 +260,7 @@ def substitute_link(app, docname, text): if docname == 'index': readme_text = '' - with codecs.open(os.path.abspath('../readme.md'), 'r', 'utf-8') as f: + with codecs.open(os.path.abspath('../readme.rst'), 'r', 'utf-8') as f: readme_text = r.sub(r'`\1 <\2>`_', f.read()) text[0] = r2.sub(readme_text, text[0]) diff --git a/readme.md b/readme.rst similarity index 93% rename from readme.md rename to readme.rst index b731173..994c477 100644 --- a/readme.md +++ b/readme.rst @@ -2,12 +2,13 @@ Feedgenerator ============= -[![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) -](https://travis-ci.org/lkiesow/python-feedgen) +.. image:: https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master + :target: https://travis-ci.org/lkiesow/python-feedgen + :alt: Build Status + 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. +has support for extensions. Included is for example an extension to produce 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 @@ -29,14 +30,13 @@ Installation If you are running Fedora Linux, RedHat Enterprise Linux, CentOS or Scientific Linux you can use the RPM Copr repostiory: -[http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/ -](http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/) +http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/ -Simply enable the repository and run: +Simply enable the repository and run:: $ yum install python-feedgen -or for the Python 3 package: +or for the Python 3 package:: $ yum install python3-feedgen @@ -183,4 +183,4 @@ You can test the module by simply executing:: If you want to have a look at the code for this test to have a working code example for a whole feed generation process, you can find it in the -[`__main__.py`](https://github.com/lkiesow/python-feedgen/blob/master/feedgen/__main__.py). +`__main__.py `_. From dea81225d1c2cee032b1eb21a49c7a804870c16f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Wed, 4 Jan 2017 02:29:10 +0100 Subject: [PATCH 05/96] Added flake8 to TravisCI Signed-off-by: Lars Kiesow --- .travis.yml | 9 ++++++--- feedgen/version.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index c0d981b..4a8a661 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,18 @@ language: python python: - - "2.6" - "2.7" - - "3.2" - "3.3" - "3.4" - "3.5" before_install: + - pip install flake8 - python setup.py bdist_wheel - pip install dist/feedgen* -script: make test +script: + - make test + - python -m feedgen + - python -m feedgen atom + - python -m feedgen rss diff --git a/feedgen/version.py b/feedgen/version.py index 92ad6d6..eebbd46 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -3,7 +3,7 @@ feedgen.version ~~~~~~~~~~~~~~~ - :copyright: 2013-2017, Lars Kiesow + :copyright: 2013-2017, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. From 1873564f14529f81d153b5e94d897db891d22b07 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 5 Jan 2017 01:06:57 +0100 Subject: [PATCH 06/96] Release 0.5.0 - Moved to reStructuredText for documentation (readme) - Adhere to PEP8 (flake8) - Fixed several minor issues Signed-off-by: Lars Kiesow --- feedgen/version.py | 2 +- python-feedgen.spec | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index eebbd46..4ace3b1 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 4, 1) +version = (0, 5, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 58b3cfe..01711b6 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.4.1 +Version: 0.5.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -93,6 +93,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* Thu Jan 05 2017 Lars Kiesow - 0.5.0-1 +- Update to 0.5.0 + * Thu Jan 05 2017 Lars Kiesow - 0.4.1-1 - Update to 0.4.1 From 0b06987ccde4628ed19d00c8f332eb492705ea53 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 5 Jan 2017 01:12:23 +0100 Subject: [PATCH 07/96] Release 0.5.1 - Fixed release issue Signed-off-by: Lars Kiesow --- feedgen/version.py | 2 +- python-feedgen.spec | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 4ace3b1..b8d9f89 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 5, 0) +version = (0, 5, 1) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 01711b6..4e83d42 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.5.0 +Version: 0.5.1 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -93,6 +93,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* Thu Jan 05 2017 Lars Kiesow - 0.5.1-1 +- Update to 0.5.1 + * Thu Jan 05 2017 Lars Kiesow - 0.5.0-1 - Update to 0.5.0 From 68248e46a048670a51021b4f72d2b018b44aa2be Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 5 Jan 2017 01:25:31 +0100 Subject: [PATCH 08/96] Fix readme packaging Signed-off-by: Lars Kiesow --- MANIFEST.in | 2 +- python-feedgen.spec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index f27e442..c6c12dc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -include license.bsd license.lgpl readme.md +include license.bsd license.lgpl readme.rst recursive-include docs *.html *.css *.png *.gif *.js diff --git a/python-feedgen.spec b/python-feedgen.spec index 4e83d42..2bdd7cd 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -45,7 +45,7 @@ Podcasts. %prep %setup -q -n %{srcname}-%{version} mkdir python2 -mv PKG-INFO docs feedgen license.bsd license.lgpl readme.md setup.py python2 +mv PKG-INFO docs feedgen license.bsd license.lgpl readme.rst setup.py python2 cp -r python2 python3 # ensure the right python version is used From 4314475bfbc8f48cbcc73896e3badb682437f922 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 5 Feb 2017 14:33:22 +0100 Subject: [PATCH 09/96] Extended test Coverage This patch extends the test coverage of the unit tests. It also enables python-coverage and coveralls to track unit test coverage. There are some additional minor issue fixed discovered during the test creation. Signed-off-by: Lars Kiesow --- .travis.yml | 5 +++- Makefile | 5 +--- feedgen/entry.py | 2 +- feedgen/ext/dc.py | 3 +- feedgen/ext/podcast_entry.py | 6 ++-- tests/test_entry.py | 46 +++++++++++++++++++++++++++--- tests/test_extension.py | 54 +++++++++++++++++++++++++++++++++++- tests/test_feed.py | 7 +++-- 8 files changed, 111 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4a8a661..dba2177 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ python: - "3.5" before_install: - - pip install flake8 + - pip install flake8 python-coveralls coverage - python setup.py bdist_wheel - pip install dist/feedgen* @@ -16,3 +16,6 @@ script: - python -m feedgen - python -m feedgen atom - python -m feedgen rss + +after_success: + - coveralls diff --git a/Makefile b/Makefile index 5eca053..4d368fd 100644 --- a/Makefile +++ b/Makefile @@ -49,8 +49,5 @@ publish: twine upload dist/* test: - python -m unittest tests.test_feed - python -m unittest tests.test_entry - python -m unittest tests.test_extension - @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml + coverage run --omit='*/lib/*,tests/*' -m unittest discover -s tests flake8 $$(find setup.py tests feedgen -name '*.py') diff --git a/feedgen/entry.py b/feedgen/entry.py index aca2a9c..7527bf3 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -464,7 +464,7 @@ def description(self, description=None, isSummary=False): if isSummary: self.__atom_summary = description else: - self.__atom_content = description + self.__atom_content = {'content': description} return self.__rss_description def category(self, category=None, replace=False, **kwargs): diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index f52b506..184a29f 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -8,7 +8,7 @@ Descriptions partly taken from http://dublincore.org/documents/dcmi-terms/#elements-coverage - :copyright: 2013-2016, Lars Kiesow + :copyright: 2013-2017, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -223,6 +223,7 @@ def dc_identifier(self, identifier=None, replace=True): if replace or not self._dcelem_identifier: self._dcelem_identifier = [] self._dcelem_identifier += identifier + return self._dcelem_identifier def dc_language(self, language=None, replace=True): '''Get or set the dc:language which describes a language of the diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 8a89e74..3f3a7d9 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -130,7 +130,7 @@ def itunes_image(self, itunes_image=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') + raise ValueError('Image file must be png or jpg') return self.__itunes_image def itunes_duration(self, itunes_duration=None): @@ -151,9 +151,9 @@ def itunes_duration(self, itunes_duration=None): itunes_duration = str(itunes_duration) if len(itunes_duration.split(':')) > 3 or \ itunes_duration.lstrip('0123456789:') != '': - ValueError('Invalid duration format') + raise ValueError('Invalid duration format') self.__itunes_duration = itunes_duration - return self.itunes_duration + return self.__itunes_duration def itunes_explicit(self, itunes_explicit=None): '''Get or the the itunes:explicit value of the podcast episode. This diff --git a/tests/test_entry.py b/tests/test_entry.py index 090968f..ead38ff 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -13,41 +13,79 @@ class TestSequenceFunctions(unittest.TestCase): def setUp(self): - fg = FeedGenerator() self.feedId = 'http://example.com' self.title = 'Some Testfeed' fg.id(self.feedId) fg.title(self.title) + fg.link(href='http://lkiesow.de', rel='alternate')[0] + fg.description('...') fe = fg.add_entry() fe.id('http://lernfunk.de/media/654321/1') fe.title('The First Episode') + fe.content(u'…') # 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.content(u'…') fe = fg.add_entry() fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') + fe.content(u'…') self.fg = fg def test_checkEntryNumbers(self): - fg = self.fg assert len(fg.entry()) == 3 - def test_checkItemNumbers(self): + def test_TestEntryItems(self): + fe = self.fg.add_item() + fe.title('qwe') + assert fe.title() == 'qwe' + author = fe.author(name='John Doe', email='jdoe@example.com')[0] + assert author.get('name') == 'John Doe' + assert author.get('email') == 'jdoe@example.com' + contributor = fe.contributor(name='John Doe', email='jdoe@ex.com')[0] + assert contributor == fe.contributor()[0] + assert contributor.get('name') == 'John Doe' + assert contributor.get('email') == 'jdoe@ex.com' + link = fe.link(href='http://lkiesow.de', rel='alternate')[0] + assert link == fe.link()[0] + assert link.get('href') == 'http://lkiesow.de' + assert link.get('rel') == 'alternate' + fe.guid('123') + assert fe.guid() == '123' + fe.updated('2017-02-05 13:26:58+01:00') + assert fe.updated().year == 2017 + fe.summary('asdf') + assert fe.summary() == 'asdf' + fe.description('asdfx') + assert fe.description() == 'asdfx' + fe.pubdate('2017-02-05 13:26:58+01:00') + assert fe.pubdate().year == 2017 + fe.rights('asdfx') + assert fe.rights() == 'asdfx' + fe.comments('asdfx') + assert fe.comments() == 'asdfx' + fe.enclosure(url='http://lkiesow.de', type='text/plain', length='1') + assert fe.enclosure().get('url') == 'http://lkiesow.de' + fe.ttl(8) + assert fe.ttl() == 8 + + self.fg.rss_str() + self.fg.atom_str() + def test_checkItemNumbers(self): fg = self.fg assert len(fg.item()) == 3 def test_checkEntryContent(self): - fg = self.fg assert fg.entry() diff --git a/tests/test_extension.py b/tests/test_extension.py index 5c0dedf..5499387 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -# vim: set et ts=4 sw=4 sts=4 sta tw=80 cc=81: """ Tests for extensions @@ -82,3 +81,56 @@ def test_category(self): namespaces=ns) assert cat[0] == 'Technology' assert scat[0] == 'Podcasting' + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.podcast.itunes_author('Lars Kiesow') + fe.podcast.itunes_block('x') + fe.podcast.itunes_duration('00:01:30') + fe.podcast.itunes_explicit('no') + fe.podcast.itunes_image('x.png') + fe.podcast.itunes_is_closed_captioned('yes') + fe.podcast.itunes_order(1) + fe.podcast.itunes_subtitle('x') + fe.podcast.itunes_summary('x') + assert fe.podcast.itunes_author() == 'Lars Kiesow' + assert fe.podcast.itunes_block() == 'x' + assert fe.podcast.itunes_duration() == '00:01:30' + assert fe.podcast.itunes_explicit() == 'no' + assert fe.podcast.itunes_image() == 'x.png' + assert fe.podcast.itunes_is_closed_captioned() + assert fe.podcast.itunes_order() == 1 + assert fe.podcast.itunes_subtitle() == 'x' + assert fe.podcast.itunes_summary() == 'x' + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/item/itunes:author/text()', + namespaces=ns) + assert author == ['Lars Kiesow'] + + +class TestExtensionDc(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('dc') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_entryLoadExtension(self): + fe = self.fg.add_item() + try: + fe.load_extension('dc') + except ImportError: + pass # Extension already loaded + + def test_elements(self): + for method in dir(self.fg.dc): + if method.startswith('dc_'): + m = getattr(self.fg.dc, method) + m(method) + assert m() == [method] diff --git a/tests/test_feed.py b/tests/test_feed.py index 2771a07..cc4a188 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -7,6 +7,8 @@ A basic feed does not contain entries so far. """ +import os +import tempfile import unittest from lxml import etree from feedgen.feed import FeedGenerator @@ -123,13 +125,14 @@ def test_baseFeed(self): def test_atomFeedFile(self): fg = self.fg - filename = 'tmp_Atomfeed.xml' + _, filename = tempfile.mkstemp() fg.atom_file(filename=filename, pretty=True, xml_declaration=False) with open(filename, "r") as myfile: atomString = myfile.read().replace('\n', '') self.checkAtomString(atomString) + os.remove(filename) def test_atomFeedString(self): fg = self.fg @@ -243,7 +246,7 @@ def checkAtomString(self, atomString): def test_rssFeedFile(self): fg = self.fg - filename = 'tmp_Rssfeed.xml' + _, filename = tempfile.mkstemp() fg.rss_file(filename=filename, pretty=True, xml_declaration=False) with open(filename, "r") as myfile: From 4c763dc8327aec9859a7fc57220c14910ae84128 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 5 Feb 2017 22:46:43 +0100 Subject: [PATCH 10/96] Added more tests Signed-off-by: Lars Kiesow --- tests/test_entry.py | 15 +++++++++++++++ tests/test_extension.py | 27 +++++++++++++++++++++++++++ tests/test_feed.py | 3 +++ 3 files changed, 45 insertions(+) diff --git a/tests/test_entry.py b/tests/test_entry.py index ead38ff..2b986cf 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -40,6 +40,21 @@ def setUp(self): self.fg = fg + def test_setEntries(self): + fg2 = FeedGenerator() + fg2.entry(self.fg.entry()) + assert len(fg2.entry()) == 3 + assert self.fg.entry() == fg2.entry() + + def test_loadExtension(self): + fe = self.fg.add_item() + fe.id('1') + fe.title(u'…') + fe.content(u'…') + fe.load_extension('base') + assert fe.base + assert self.fg.atom_str() + def test_checkEntryNumbers(self): fg = self.fg assert len(fg.entry()) == 3 diff --git a/tests/test_extension.py b/tests/test_extension.py index 5499387..c1506a9 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -82,6 +82,29 @@ def test_category(self): assert cat[0] == 'Technology' assert scat[0] == 'Podcasting' + def test_podcastItems(self): + fg = self.fg + fg.podcast.itunes_author('Lars Kiesow') + fg.podcast.itunes_block('x') + fg.podcast.itunes_complete(False) + fg.podcast.itunes_explicit('no') + fg.podcast.itunes_image('x.png') + fg.podcast.itunes_subtitle('x') + fg.podcast.itunes_summary('x') + assert fg.podcast.itunes_author() == 'Lars Kiesow' + assert fg.podcast.itunes_block() == 'x' + assert fg.podcast.itunes_complete() == 'no' + assert fg.podcast.itunes_explicit() == 'no' + assert fg.podcast.itunes_image() == 'x.png' + assert fg.podcast.itunes_subtitle() == 'x' + assert fg.podcast.itunes_summary() == 'x' + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) + assert author == ['Lars Kiesow'] + def test_podcastEntryItems(self): fe = self.fg.add_item() fe.title('y') @@ -134,3 +157,7 @@ def test_elements(self): m = getattr(self.fg.dc, method) m(method) assert m() == [method] + + self.fg.id('123') + assert self.fg.atom_str() + assert self.fg.rss_str() diff --git a/tests/test_feed.py b/tests/test_feed.py index cc4a188..853a724 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -100,6 +100,9 @@ def setUp(self): name=self.textInputName, link=self.textInputLink) fg.ttl(self.ttl) fg.webMaster(self.webMaster) + fg.updated('2017-02-05 13:26:58+01:00') + fg.pubDate('2017-02-05 13:26:58+01:00') + fg.generator('python-feedgen', 'x', uri='http://github.com/lkie...') self.fg = fg From 04f5362a4131f80f5aadd063a176ad9a27c082f6 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 5 Feb 2017 23:17:41 +0100 Subject: [PATCH 11/96] Added coveralls badge Signed-off-by: Lars Kiesow --- readme.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/readme.rst b/readme.rst index 994c477..2355575 100644 --- a/readme.rst +++ b/readme.rst @@ -6,6 +6,10 @@ Feedgenerator :target: https://travis-ci.org/lkiesow/python-feedgen :alt: Build Status +.. image:: https://coveralls.io/repos/lkiesow/python-feedgen/badge.png?branch=master + :target: https://coveralls.io/r/lkiesow/python-feedgen + :alt: Test Coverage Status + 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. From 84dd4373aebcdc3a77440170e1583007f8c81a2b Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 5 Feb 2017 23:22:15 +0100 Subject: [PATCH 12/96] Fix coveralls badge Signed-off-by: Lars Kiesow --- readme.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.rst b/readme.rst index 2355575..85600c9 100644 --- a/readme.rst +++ b/readme.rst @@ -6,8 +6,8 @@ Feedgenerator :target: https://travis-ci.org/lkiesow/python-feedgen :alt: Build Status -.. image:: https://coveralls.io/repos/lkiesow/python-feedgen/badge.png?branch=master - :target: https://coveralls.io/r/lkiesow/python-feedgen +.. image:: https://coveralls.io/repos/github/lkiesow/python-feedgen/badge.svg?branch=master + :target: https://coveralls.io/github/lkiesow/python-feedgen?branch=master :alt: Test Coverage Status From 770603320032a6afc7e82b27bcb4926468fa22d8 Mon Sep 17 00:00:00 2001 From: Stefan Bisplinghoff Date: Mon, 13 Mar 2017 13:17:22 +0100 Subject: [PATCH 13/96] new entries are inserted to the front of the __feed_entries list --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 1faf3e3..6bf55d9 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -1030,7 +1030,7 @@ def add_entry(self, feedEntry=None): except ImportError: pass - self.__feed_entries.append(feedEntry) + self.__feed_entries.insert(0, feedEntry) return feedEntry def add_item(self, item=None): From f36ff20898e14dc2b66af9af307784d8bf2c3942 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 13 Mar 2017 20:53:04 +0100 Subject: [PATCH 14/96] Update Coverage Command This command will automatically check the coverage of the whole project on not only the modules called by the tests. Signed-off-by: Lars Kiesow --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4d368fd..50c88d9 100644 --- a/Makefile +++ b/Makefile @@ -49,5 +49,5 @@ publish: twine upload dist/* test: - coverage run --omit='*/lib/*,tests/*' -m unittest discover -s tests + coverage run --source=feedgen -m unittest discover -s tests flake8 $$(find setup.py tests feedgen -name '*.py') From 8901310fa08866b634f8c84fb02d2bb68b3fb370 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 13 Mar 2017 20:55:14 +0100 Subject: [PATCH 15/96] Added Torrent Tests This patch adds tests for the torrent extension. It also fixes a minor bug discovered by the new tests. Signed-off-by: Lars Kiesow --- feedgen/ext/torrent.py | 3 ++- tests/test_extension.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index 29e8b4b..a2eb49c 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -32,6 +32,7 @@ def __init__(self): self.__torrent_contentlength = None self.__torrent_seeds = None self.__torrent_peers = None + self.__torrent_verified = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -61,7 +62,7 @@ def extend_rss(self, entry): peers = etree.SubElement(entry, '{%s}peers' % TORRENT_NS) peers.text = self.__torrent_peers - if self.__torrent_seeds: + if self.__torrent_verified: verified = etree.SubElement(entry, '{%s}verified' % TORRENT_NS) verified.text = self.__torrent_verified diff --git a/tests/test_extension.py b/tests/test_extension.py index c1506a9..3c41b70 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -161,3 +161,36 @@ def test_elements(self): self.fg.id('123') assert self.fg.atom_str() assert self.fg.rss_str() + + +class TestExtensionTorrent(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('torrent') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.torrent.filename('file.xy') + fe.torrent.infohash('123') + fe.torrent.contentlength('23') + fe.torrent.seeds('1') + fe.torrent.peers('2') + fe.torrent.verified('1') + assert fe.torrent.filename() == 'file.xy' + assert fe.torrent.infohash() == '123' + assert fe.torrent.contentlength() == '23' + assert fe.torrent.seeds() == '1' + assert fe.torrent.peers() == '2' + assert fe.torrent.verified() == '1' + + # Check that we have the item in the resulting XML + ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} + root = etree.fromstring(self.fg.rss_str()) + filename = root.xpath('/rss/channel/item/torrent:filename/text()', + namespaces=ns) + assert filename == ['file.xy'] From fee00f3337cb2813047e77beaf629fcc92814dab Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 13 Mar 2017 20:56:59 +0100 Subject: [PATCH 16/96] More Feed Tests Signed-off-by: Lars Kiesow --- tests/test_feed.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_feed.py b/tests/test_feed.py index 853a724..664b3af 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -103,6 +103,12 @@ def setUp(self): fg.updated('2017-02-05 13:26:58+01:00') fg.pubDate('2017-02-05 13:26:58+01:00') fg.generator('python-feedgen', 'x', uri='http://github.com/lkie...') + fg.image(url=self.logo, + title=self.title, + link=self.link2Href, + width='123', + height='123', + description='Example Inage') self.fg = fg From c633fd4d646a5f8cc93650beeb8e8488f57d28fc Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 13 Mar 2017 20:57:52 +0100 Subject: [PATCH 17/96] Add tests for feedgen:main This patch adds some tests for the main routine of feedgen. Signed-off-by: Lars Kiesow --- feedgen/__main__.py | 6 +++++- tests/test_main.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/test_main.py diff --git a/feedgen/__main__.py b/feedgen/__main__.py index f072895..2cf1346 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -44,7 +44,7 @@ def print_enc(s): print(s) -if __name__ == '__main__': +def main(): if len(sys.argv) != 2 or not ( sys.argv[1].endswith('rss') or sys.argv[1].endswith('atom') or @@ -138,3 +138,7 @@ def print_enc(s): elif arg.endswith('rss'): fg.rss_file(arg) + + +if __name__ == '__main__': + main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..7f528ef --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +''' +Tests for feedgen main +''' + +import os +import sys +import tempfile +import unittest +from feedgen import __main__ + + +class TestSequenceFunctions(unittest.TestCase): + + def test_usage(self): + sys.argv = ['feedgen'] + try: + __main__.main() + except BaseException as e: + assert e.code is None + + def test_feed(self): + for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', 'dc.atom',\ + 'syndication.rss', 'syndication.atom': + sys.argv = ['feedgen', ftype] + try: + __main__.main() + except: + assert False + + def test_file(self): + for extemsion in '.atom', '.rss': + _, filename = tempfile.mkstemp(extemsion) + sys.argv = ['feedgen', filename] + try: + __main__.main() + except: + assert False + os.remove(filename) From 688b1d53c43242c3f26681a9fdbc382e592d81df Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 13 Mar 2017 21:02:38 +0100 Subject: [PATCH 18/96] Fixed flake8 complaint Signed-off-by: Lars Kiesow --- tests/test_extension.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_extension.py b/tests/test_extension.py index 3c41b70..d717e20 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -192,5 +192,5 @@ def test_podcastEntryItems(self): ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} root = etree.fromstring(self.fg.rss_str()) filename = root.xpath('/rss/channel/item/torrent:filename/text()', - namespaces=ns) + namespaces=ns) assert filename == ['file.xy'] From f6d11937b447f76c3b18af6f4decff280f7f2d58 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 13 Mar 2017 21:04:09 +0100 Subject: [PATCH 19/96] Added Python 3.6 in Travis Signed-off-by: Lars Kiesow --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index dba2177..c0228e7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: - "3.3" - "3.4" - "3.5" + - "3.6" before_install: - pip install flake8 python-coveralls coverage From f0c066af0f133437cd6f821bd106cef65af7ff10 Mon Sep 17 00:00:00 2001 From: Stefan Bisplinghoff Date: Thu, 16 Mar 2017 16:16:46 +0100 Subject: [PATCH 20/96] Unit Tests: Temporary file handles need to be closed explicitly under Windows --- tests/test_feed.py | 3 ++- tests/test_main.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_feed.py b/tests/test_feed.py index 664b3af..ffd8e4b 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -134,13 +134,14 @@ def test_baseFeed(self): def test_atomFeedFile(self): fg = self.fg - _, filename = tempfile.mkstemp() + fh, filename = tempfile.mkstemp() fg.atom_file(filename=filename, pretty=True, xml_declaration=False) with open(filename, "r") as myfile: atomString = myfile.read().replace('\n', '') self.checkAtomString(atomString) + os.close(fh) os.remove(filename) def test_atomFeedString(self): diff --git a/tests/test_main.py b/tests/test_main.py index 7f528ef..a6c6b88 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -31,10 +31,11 @@ def test_feed(self): def test_file(self): for extemsion in '.atom', '.rss': - _, filename = tempfile.mkstemp(extemsion) + fh, filename = tempfile.mkstemp(extemsion) sys.argv = ['feedgen', filename] try: __main__.main() except: assert False + os.close(fh) os.remove(filename) From 9ac741ac0e25bfd75ecf97fd7e5383225085360b Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 17 Mar 2017 17:50:33 +0100 Subject: [PATCH 21/96] Add tests to setup.py Signed-off-by: Lars Kiesow --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 7b7ae11..2bfafc2 100755 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ 'Topic :: Text Processing :: Markup', 'Topic :: Text Processing :: Markup :: XML' ], + test_suite="tests", long_description='''\ Feedgenerator ============= From ba5bbd2256ce4316cf7fb7040fcd6c870e5cd89c Mon Sep 17 00:00:00 2001 From: Lukas Rusak Date: Tue, 28 Mar 2017 12:31:17 -0700 Subject: [PATCH 22/96] add new media entry extension --- feedgen/ext/media.py | 176 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 feedgen/ext/media.py diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py new file mode 100644 index 0000000..40f506a --- /dev/null +++ b/feedgen/ext/media.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.media + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + Extends the feedgen to produce media tags. + + :copyright: 2013-2016, Lars Kiesow + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from lxml import etree +from feedgen.ext.base import BaseExtension, BaseEntryExtension + +MEDIA_NS = 'http://search.yahoo.com/mrss/' + + +class MediaExtension(BaseExtension): + '''FeedGenerator extension for torrent feeds. + ''' + + def extend_ns(self): + return {'media': MEDIA_NS} + + +class MediaEntryExtension(BaseEntryExtension): + '''FeedEntry extension for media tags. + ''' + + def __init__(self): + self.__media_content = None + self.__media_thumbnail = None + + def extend_atom(self, entry): + '''Add additional fields to an RSS item. + + :param feed: The RSS item XML element to use. + ''' + + group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) + if self.__media_content: + content = etree.SubElement(group, '{%s}content' % MEDIA_NS) + if self.__media_content.get('url'): + content.set('url', self.__media_content.get('url')) + if self.__media_content.get('fileSize'): + content.set('fileSize', self.__media_content.get('fileSize')) + if self.__media_content.get('type'): + content.set('type', self.__media_content.get('type')) + if self.__media_content.get('medium'): + content.set('medium', self.__media_content.get('medium')) + if self.__media_content.get('isDefault'): + content.set('isDefault', self.__media_content.get('isDefault')) + if self.__media_content.get('expression'): + content.set( + 'expression', self.__media_content.get('expression')) + if self.__media_content.get('bitrate'): + content.set('bitrate', self.__media_content.get('bitrate')) + if self.__media_content.get('framerate'): + content.set('framerate', self.__media_content.get('framerate')) + if self.__media_content.get('samplingrate'): + content.set('samplingrate', + self.__media_content.get('samplingrate')) + if self.__media_content.get('channels'): + content.set('channels', self.__media_content.get('channels')) + if self.__media_content.get('duration'): + content.set('duration', self.__media_content.get('duration')) + if self.__media_content.get('height'): + content.set('height', self.__media_content.get('height')) + if self.__media_content.get('width'): + content.set('width', self.__media_content.get('width')) + if self.__media_content.get('lang'): + content.set('lang', self.__media_content.get('lang')) + if self.__media_thumbnail: + thumbnail = etree.SubElement(group, '{%s}thumbnail' % MEDIA_NS) + if self.__media_thumbnail.get('url'): + thumbnail.set('url', self.__media_thumbnail.get('url')) + if self.__media_thumbnail.get('height'): + thumbnail.set('height', self.__media_thumbnail.get('height')) + if self.__media_thumbnail.get('width'): + thumbnail.set('width', self.__media_thumbnail.get('width')) + if self.__media_thumbnail.get('lang'): + thumbnail.set('lang', self.__media_thumbnail.get('lang')) + + return entry + + def media_content(self, url=None, fileSize=None, type=None, medium=None, + isDefault=None, expression=None, bitrate=None, + framerate=None, samplingrate=None, channels=None, + duration=None, height=None, width=None, lang=None): + ''' is a sub-element of either or . + Media objects that are not the same content should not be included in + the same element. The sequence of these items implies + the order of presentation. While many of the attributes appear to be + audio/video specific, this element can be used to publish any type + of media. It contains 14 attributes, most of which are optional. + + :param url: should specify the direct URL to the media object. + :param fileSize: number of bytes of the media object. + :param type: standard MIME type of the object. + :param medium: type of object + (image | audio | video | document | executable). + :param isDefault: determines if this is the default object. + :param expression: determines if the object is a sample or the full + version of the object, or even if it is a + continuous stream (sample | full | nonstop). + :param bitrate: kilobits per second rate of media. + :param framerate: number of frames per second for the media object. + :param samplingrate: number of samples per second taken to create the + media object. It is expressed in thousands of + samples per second (kHz). + :param channels: number of audio channels in the media object. + :param duration: number of seconds the media object plays. + :param height: height of the media object. + :param width: width of the media object. + :param lang: is the primary language encapsulated in the media object. + + :returns: The media content tag. + ''' + + if url is not None: + self.__media_content = {'url': url} + if fileSize is not None: + self.__media_content['fileSize'] = fileSize + if type is not None: + self.__media_content['type'] = type + if medium is not None: + self.__media_content['medium'] = medium + if isDefault is not None: + self.__media_content['isDefault'] = isDefault + if expression is not None: + self.__media_content['expression'] = expression + if bitrate is not None: + self.__media_content['bitrate'] = bitrate + if framerate is not None: + self.__media_content['framerate'] = framerate + if samplingrate is not None: + self.__media_content['samplingrate'] = samplingrate + if channels is not None: + self.__media_content['channels'] = channels + if duration is not None: + self.__media_content['duration'] = duration + if height is not None: + self.__media_content['height'] = height + if width is not None: + self.__media_content['width'] = width + if lang is not None: + self.__media_content['lang'] = lang + + return self.__media_content + + def media_thumbnail(self, url=None, height=None, width=None, time=None): + '''Allows particular images to be used as representative images for + the media object. If multiple thumbnails are included, and time + coding is not at play, it is assumed that the images are in order + of importance. It has one required attribute and three optional + attributes. + + :param url: should specify the direct URL to the media object. + :param height: height of the media object. + :param width: width of the media object. + :param time: specifies the time offset in relation to the media object. + + :returns: The media thumbnail tag. + ''' + + if url is not None: + self.__media_thumbnail = {'url': url} + if height is not None: + self.__media_thumbnail['height'] = height + if width is not None: + self.__media_thumbnail['width'] = width + if time is not None: + self.__media_thumbnail['time'] = time + + return self.__media_thumbnail From 8487af298eaa2fb79612d7823442ed7ec5462aa6 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 21 May 2017 12:48:29 +0200 Subject: [PATCH 23/96] Add Media RSS to RSS This patch adds the necessary RSS extension to ensure that added Media RSS elements end up in the resulting RSS XML. Part of #58 --- feedgen/ext/media.py | 7 +++++-- tests/test_extension.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py index 40f506a..e947f8a 100644 --- a/feedgen/ext/media.py +++ b/feedgen/ext/media.py @@ -5,7 +5,7 @@ Extends the feedgen to produce media tags. - :copyright: 2013-2016, Lars Kiesow + :copyright: 2013-2017, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -29,7 +29,7 @@ class MediaEntryExtension(BaseEntryExtension): ''' def __init__(self): - self.__media_content = None + self.__media_content = [] self.__media_thumbnail = None def extend_atom(self, entry): @@ -84,6 +84,9 @@ def extend_atom(self, entry): return entry + def extend_rss(self, item): + return self.extend_atom(item) + def media_content(self, url=None, fileSize=None, type=None, medium=None, isDefault=None, expression=None, bitrate=None, framerate=None, samplingrate=None, channels=None, diff --git a/tests/test_extension.py b/tests/test_extension.py index d717e20..bcb58bb 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -194,3 +194,35 @@ def test_podcastEntryItems(self): filename = root.xpath('/rss/channel/item/torrent:filename/text()', namespaces=ns) assert filename == ['file.xy'] + + +class TestExtensionMedia(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('media') + self.fg.id('id') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_media_content(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.media_content(url='file1.xy') + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', + namespaces=ns) + assert url == ['file1.xy'] + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', + namespaces=ns) + assert url == ['file1.xy'] From a7ae36cb4f8cbcd09fc2ff5a99e73a5a017e4899 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 21 May 2017 12:58:13 +0200 Subject: [PATCH 24/96] Remove redundant media from method name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using `fe.media.media_content(…)` is quite redundant. iThis patch changes the name to `fe.media.content(…)`. The same goes for the thumbnail method. Part of #58 --- feedgen/ext/media.py | 10 +++++----- tests/test_extension.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py index e947f8a..0410274 100644 --- a/feedgen/ext/media.py +++ b/feedgen/ext/media.py @@ -87,10 +87,10 @@ def extend_atom(self, entry): def extend_rss(self, item): return self.extend_atom(item) - def media_content(self, url=None, fileSize=None, type=None, medium=None, - isDefault=None, expression=None, bitrate=None, - framerate=None, samplingrate=None, channels=None, - duration=None, height=None, width=None, lang=None): + def content(self, url=None, fileSize=None, type=None, medium=None, + isDefault=None, expression=None, bitrate=None, framerate=None, + samplingrate=None, channels=None, duration=None, height=None, + width=None, lang=None): ''' is a sub-element of either or . Media objects that are not the same content should not be included in the same element. The sequence of these items implies @@ -152,7 +152,7 @@ def media_content(self, url=None, fileSize=None, type=None, medium=None, return self.__media_content - def media_thumbnail(self, url=None, height=None, width=None, time=None): + def thumbnail(self, url=None, height=None, width=None, time=None): '''Allows particular images to be used as representative images for the media object. If multiple thumbnails are included, and time coding is not at play, it is assumed that the images are in order diff --git a/tests/test_extension.py b/tests/test_extension.py index bcb58bb..fc69198 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -211,7 +211,7 @@ def test_media_content(self): fe.id('id') fe.title('title') fe.content('content') - fe.media.media_content(url='file1.xy') + fe.media.content(url='file1.xy') ns = {'media': 'http://search.yahoo.com/mrss/', 'a': 'http://www.w3.org/2005/Atom'} From 4970dab6d7561ed0dc08a97d84884d79a3c28c39 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 21 May 2017 22:06:50 +0200 Subject: [PATCH 25/96] Enable Multiple media:content/media:group Elements One item can have multiple media:content elements which may be located in multiple media:group element to indicate that content is the same but for the format. This patch adds the ability to add multiple content elements and define the group to which they go belong. If no group is specified, all elements are located in a `default` group. If the group is set to None, the content element is directly attached to the item element. Part of #58 --- feedgen/ext/media.py | 156 ++++++++++++++++++---------------------- feedgen/util.py | 2 +- tests/test_extension.py | 16 ++++- 3 files changed, 84 insertions(+), 90 deletions(-) diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py index 0410274..62e86df 100644 --- a/feedgen/ext/media.py +++ b/feedgen/ext/media.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- ''' feedgen.ext.media - ~~~~~~~~~~~~~~~~~~~~~~~~~ + ~~~~~~~~~~~~~~~~~ Extends the feedgen to produce media tags. @@ -11,6 +11,7 @@ ''' from lxml import etree +from feedgen.util import ensure_format from feedgen.ext.base import BaseExtension, BaseEntryExtension MEDIA_NS = 'http://search.yahoo.com/mrss/' @@ -38,39 +39,21 @@ def extend_atom(self, entry): :param feed: The RSS item XML element to use. ''' - group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) - if self.__media_content: + groups = {None: entry} + for media_content in self.__media_content: + # Define current media:group + group = groups.get(media_content.get('group')) + if group is None: + group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) + groups[media_content.get('group')] = group + # Add content content = etree.SubElement(group, '{%s}content' % MEDIA_NS) - if self.__media_content.get('url'): - content.set('url', self.__media_content.get('url')) - if self.__media_content.get('fileSize'): - content.set('fileSize', self.__media_content.get('fileSize')) - if self.__media_content.get('type'): - content.set('type', self.__media_content.get('type')) - if self.__media_content.get('medium'): - content.set('medium', self.__media_content.get('medium')) - if self.__media_content.get('isDefault'): - content.set('isDefault', self.__media_content.get('isDefault')) - if self.__media_content.get('expression'): - content.set( - 'expression', self.__media_content.get('expression')) - if self.__media_content.get('bitrate'): - content.set('bitrate', self.__media_content.get('bitrate')) - if self.__media_content.get('framerate'): - content.set('framerate', self.__media_content.get('framerate')) - if self.__media_content.get('samplingrate'): - content.set('samplingrate', - self.__media_content.get('samplingrate')) - if self.__media_content.get('channels'): - content.set('channels', self.__media_content.get('channels')) - if self.__media_content.get('duration'): - content.set('duration', self.__media_content.get('duration')) - if self.__media_content.get('height'): - content.set('height', self.__media_content.get('height')) - if self.__media_content.get('width'): - content.set('width', self.__media_content.get('width')) - if self.__media_content.get('lang'): - content.set('lang', self.__media_content.get('lang')) + for attr in ('url', 'fileSize', 'type', 'medium', 'isDefault', + 'expression', 'bitrate', 'framerate', 'samplingrate', + 'channels', 'duration', 'height', 'width', 'lang'): + if media_content.get(attr): + content.set(attr, media_content[attr]) + if self.__media_thumbnail: thumbnail = etree.SubElement(group, '{%s}thumbnail' % MEDIA_NS) if self.__media_thumbnail.get('url'): @@ -87,69 +70,68 @@ def extend_atom(self, entry): def extend_rss(self, item): return self.extend_atom(item) - def content(self, url=None, fileSize=None, type=None, medium=None, - isDefault=None, expression=None, bitrate=None, framerate=None, - samplingrate=None, channels=None, duration=None, height=None, - width=None, lang=None): - ''' is a sub-element of either or . + def content(self, content=None, replace=False, group='default', **kwargs): + '''Get or set media:content data. + + This method can be called with: + - the fields of a media:content as keyword arguments + - the fields of a media:content as a dictionary + - a list of dictionaries containing the media:content fields + + is a sub-element of either or . Media objects that are not the same content should not be included in the same element. The sequence of these items implies the order of presentation. While many of the attributes appear to be audio/video specific, this element can be used to publish any type of media. It contains 14 attributes, most of which are optional. - :param url: should specify the direct URL to the media object. - :param fileSize: number of bytes of the media object. - :param type: standard MIME type of the object. - :param medium: type of object - (image | audio | video | document | executable). - :param isDefault: determines if this is the default object. - :param expression: determines if the object is a sample or the full - version of the object, or even if it is a - continuous stream (sample | full | nonstop). - :param bitrate: kilobits per second rate of media. - :param framerate: number of frames per second for the media object. - :param samplingrate: number of samples per second taken to create the - media object. It is expressed in thousands of - samples per second (kHz). - :param channels: number of audio channels in the media object. - :param duration: number of seconds the media object plays. - :param height: height of the media object. - :param width: width of the media object. - :param lang: is the primary language encapsulated in the media object. + media:content has the following fields: + - *url* should specify the direct URL to the media object. + - *fileSize* number of bytes of the media object. + - *type* standard MIME type of the object. + - *medium* type of object (image | audio | video | document | + executable). + - *isDefault* determines if this is the default object. + - *expression* determines if the object is a sample or the full version + of the object, or even if it is a continuous stream (sample | full | + nonstop). + - *bitrate* kilobits per second rate of media. + - *framerate* number of frames per second for the media object. + - *samplingrate* number of samples per second taken to create the media + object. It is expressed in thousands of samples per second (kHz). + - *channels* number of audio channels in the media object. + - *duration* number of seconds the media object plays. + - *height* height of the media object. + - *width* width of the media object. + - *lang* is the primary language encapsulated in the media object. + + :param content: Dictionary or list of dictionaries with content data. + :param replace: Add or replace old data. + :param group: Media group to put this content in. :returns: The media content tag. ''' - - if url is not None: - self.__media_content = {'url': url} - if fileSize is not None: - self.__media_content['fileSize'] = fileSize - if type is not None: - self.__media_content['type'] = type - if medium is not None: - self.__media_content['medium'] = medium - if isDefault is not None: - self.__media_content['isDefault'] = isDefault - if expression is not None: - self.__media_content['expression'] = expression - if bitrate is not None: - self.__media_content['bitrate'] = bitrate - if framerate is not None: - self.__media_content['framerate'] = framerate - if samplingrate is not None: - self.__media_content['samplingrate'] = samplingrate - if channels is not None: - self.__media_content['channels'] = channels - if duration is not None: - self.__media_content['duration'] = duration - if height is not None: - self.__media_content['height'] = height - if width is not None: - self.__media_content['width'] = width - if lang is not None: - self.__media_content['lang'] = lang - + # Handle kwargs + if content is None and kwargs: + content = kwargs + # Handle new data + if content is not None: + # Reset data if we want to replace them + if replace or self.__media_content is None: + self.__media_content = [] + # Ensure list + if not isinstance(content, list): + content = [content] + # define media group + for c in content: + c['group'] = c.get('group', group) + self.__media_content += ensure_format( + content, + set(['url', 'fileSize', 'type', 'medium', 'isDefault', + 'expression', 'bitrate', 'framerate', 'samplingrate', + 'channels', 'duration', 'height', 'width', 'lang', + 'group']), + set(['url', 'group'])) return self.__media_content def thumbnail(self, url=None, height=None, width=None, time=None): diff --git a/feedgen/util.py b/feedgen/util.py index 228f0ec..e361651 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -26,7 +26,7 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): :returns: List of checked dictionaries. ''' if not val: - return None + return [] if allowed_values is None: allowed_values = {} if defaults is None: diff --git a/tests/test_extension.py b/tests/test_extension.py index fc69198..a5ba781 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -212,6 +212,10 @@ def test_media_content(self): fe.title('title') fe.content('content') fe.media.content(url='file1.xy') + fe.media.content(url='file2.xy') + fe.media.content(url='file1.xy', group=2) + fe.media.content(url='file2.xy', group=2) + fe.media.content(url='file.xy', group=None) ns = {'media': 'http://search.yahoo.com/mrss/', 'a': 'http://www.w3.org/2005/Atom'} @@ -219,10 +223,18 @@ def test_media_content(self): root = etree.fromstring(self.fg.rss_str()) url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', namespaces=ns) - assert url == ['file1.xy'] + assert url == ['file1.xy', 'file1.xy'] + + # There is one without a group + url = root.xpath('/rss/channel/item/media:content[1]/@url', + namespaces=ns) + assert url == ['file.xy'] # Check that we have the item in the resulting Atom feed root = etree.fromstring(self.fg.atom_str()) url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', namespaces=ns) - assert url == ['file1.xy'] + assert url == ['file1.xy', 'file1.xy'] + + fe.media.content(content=[], replace=True) + assert fe.media.content() == [] From 4a0a2663e90e9969f4ca5a5fbdc4a0e7cb7a0e55 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 21 May 2017 23:46:36 +0200 Subject: [PATCH 26/96] Enable Multiple media:thumbnail Elements As with media:content, there can be multiple media:thumbnail elements in an item and in multiple groups. This patch adds the ability to do this in the same manner as with the content. Part of #58 --- feedgen/ext/media.py | 75 +++++++++++++++++++++++++++-------------- tests/test_extension.py | 34 +++++++++++++++++++ 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py index 62e86df..0bd5845 100644 --- a/feedgen/ext/media.py +++ b/feedgen/ext/media.py @@ -31,7 +31,7 @@ class MediaEntryExtension(BaseEntryExtension): def __init__(self): self.__media_content = [] - self.__media_thumbnail = None + self.__media_thumbnail = [] def extend_atom(self, entry): '''Add additional fields to an RSS item. @@ -54,16 +54,17 @@ def extend_atom(self, entry): if media_content.get(attr): content.set(attr, media_content[attr]) - if self.__media_thumbnail: + for media_thumbnail in self.__media_thumbnail: + # Define current media:group + group = groups.get(media_thumbnail.get('group')) + if group is None: + group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) + groups[media_thumbnail.get('group')] = group + # Add thumbnails thumbnail = etree.SubElement(group, '{%s}thumbnail' % MEDIA_NS) - if self.__media_thumbnail.get('url'): - thumbnail.set('url', self.__media_thumbnail.get('url')) - if self.__media_thumbnail.get('height'): - thumbnail.set('height', self.__media_thumbnail.get('height')) - if self.__media_thumbnail.get('width'): - thumbnail.set('width', self.__media_thumbnail.get('width')) - if self.__media_thumbnail.get('lang'): - thumbnail.set('lang', self.__media_thumbnail.get('lang')) + for attr in ('url', 'height', 'width', 'time'): + if media_thumbnail.get(attr): + thumbnail.set(attr, media_thumbnail[attr]) return entry @@ -134,28 +135,50 @@ def content(self, content=None, replace=False, group='default', **kwargs): set(['url', 'group'])) return self.__media_content - def thumbnail(self, url=None, height=None, width=None, time=None): - '''Allows particular images to be used as representative images for + def thumbnail(self, thumbnail=None, replace=False, group='default', + **kwargs): + '''Get or set media:thumbnail data. + + This method can be called with: + - the fields of a media:content as keyword arguments + - the fields of a media:content as a dictionary + - a list of dictionaries containing the media:content fields + + Allows particular images to be used as representative images for the media object. If multiple thumbnails are included, and time coding is not at play, it is assumed that the images are in order of importance. It has one required attribute and three optional attributes. - :param url: should specify the direct URL to the media object. - :param height: height of the media object. - :param width: width of the media object. - :param time: specifies the time offset in relation to the media object. + media:thumbnail has the following fields: + - *url* should specify the direct URL to the media object. + - *height* height of the media object. + - *width* width of the media object. + - *time* specifies the time offset in relation to the media object. + + :param thumbnail: Dictionary or list of dictionaries with thumbnail + data. + :param replace: Add or replace old data. + :param group: Media group to put this content in. :returns: The media thumbnail tag. ''' - - if url is not None: - self.__media_thumbnail = {'url': url} - if height is not None: - self.__media_thumbnail['height'] = height - if width is not None: - self.__media_thumbnail['width'] = width - if time is not None: - self.__media_thumbnail['time'] = time - + # Handle kwargs + if thumbnail is None and kwargs: + thumbnail = kwargs + # Handle new data + if thumbnail is not None: + # Reset data if we want to replace them + if replace or self.__media_thumbnail is None: + self.__media_thumbnail = [] + # Ensure list + if not isinstance(thumbnail, list): + thumbnail = [thumbnail] + # Define media group + for t in thumbnail: + t['group'] = t.get('group', group) + self.__media_thumbnail += ensure_format( + thumbnail, + set(['url', 'height', 'width', 'time', 'group']), + set(['url', 'group'])) return self.__media_thumbnail diff --git a/tests/test_extension.py b/tests/test_extension.py index a5ba781..bc4d335 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -238,3 +238,37 @@ def test_media_content(self): fe.media.content(content=[], replace=True) assert fe.media.content() == [] + + def test_media_thumbnail(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.thumbnail(url='file1.xy') + fe.media.thumbnail(url='file2.xy') + fe.media.thumbnail(url='file1.xy', group=2) + fe.media.thumbnail(url='file2.xy', group=2) + fe.media.thumbnail(url='file.xy', group=None) + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath( + '/rss/channel/item/media:group/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + # There is one without a group + url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file.xy'] + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + fe.media.thumbnail(thumbnail=[], replace=True) + assert fe.media.thumbnail() == [] From c0a1acc18c46aff92c76e6b84b6d55534195bc76 Mon Sep 17 00:00:00 2001 From: Bob Breznak Date: Tue, 12 Sep 2017 10:50:26 -0400 Subject: [PATCH 27/96] Add Simple GeoRSS support --- feedgen/ext/geo.py | 15 +++++++++++++++ feedgen/ext/geo_entry.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 feedgen/ext/geo.py create mode 100644 feedgen/ext/geo_entry.py diff --git a/feedgen/ext/geo.py b/feedgen/ext/geo.py new file mode 100644 index 0000000..e4370e3 --- /dev/null +++ b/feedgen/ext/geo.py @@ -0,0 +1,15 @@ +from lxml import etree +from feedgen.ext.base import BaseExtension + +class GeoExtension(BaseExtension): + def __init__(self): + self.__point__ = None + + def extend_ns(self): + return { 'georss' : 'http://www.georss.org/georss' } + + def extend_rss(self, rss_feed): + return rss_feed + + def extend_atom(self, atom_feed): + return atom_feed diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py new file mode 100644 index 0000000..d158c4b --- /dev/null +++ b/feedgen/ext/geo_entry.py @@ -0,0 +1,22 @@ +from lxml import etree +from feedgen.ext.base import BaseEntryExtension + +class GeoEntryExtension(BaseEntryExtension): + def __init__(self): + self.__point = None + + def extend_rss(self, entry): + GEO_NS = 'http://www.georss.org/georss' + + if self.__point: + point = etree.SubElement(entry, '{%s}point' % GEO_NS) + point.text = self.__point + + return entry + + def extend_atom(self, entry): + return self.extend_rss(self, entry) + + def point(self, point=None): + self.__point = point or '0.0 0.0' + return self.__point From ae5759d133251c1e5d035d95529e169a0ae41093 Mon Sep 17 00:00:00 2001 From: Bob Breznak Date: Tue, 12 Sep 2017 15:50:05 -0400 Subject: [PATCH 28/96] Better format Geo and GeoEntry. Add test for Geo Extension --- feedgen/ext/geo.py | 23 ++++++++++++++--------- feedgen/ext/geo_entry.py | 38 +++++++++++++++++++++++++++++++++++--- tests/test_extension.py | 23 +++++++++++++++++++++++ 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/feedgen/ext/geo.py b/feedgen/ext/geo.py index e4370e3..1f31a2b 100644 --- a/feedgen/ext/geo.py +++ b/feedgen/ext/geo.py @@ -1,15 +1,20 @@ -from lxml import etree +# -*- coding: utf-8 -*- +''' + feedgen.ext.geo + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to produce Simple GeoRSS feeds. + + :copyright: 2017, Bob Breznak + + :license: FreeBSD and LGPL, see license.* for more details. +''' + from feedgen.ext.base import BaseExtension class GeoExtension(BaseExtension): - def __init__(self): - self.__point__ = None + '''FeedGenerator extension for Simple GeoRSS. + ''' def extend_ns(self): return { 'georss' : 'http://www.georss.org/georss' } - - def extend_rss(self, rss_feed): - return rss_feed - - def extend_atom(self, atom_feed): - return atom_feed diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index d158c4b..f57fe26 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -1,11 +1,32 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.geo_entry + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to produce Simple GeoRSS feeds. + + :copyright: 2017, Bob Breznak + + :license: FreeBSD and LGPL, see license.* for more details. +''' + from lxml import etree from feedgen.ext.base import BaseEntryExtension class GeoEntryExtension(BaseEntryExtension): + '''FeedEntry extension for Simple GeoRSS. + ''' + def __init__(self): + # Simple GeoRSS tag self.__point = None - def extend_rss(self, entry): + def extend_file(self, entry): + '''Add additional fields to an RSS item. + + :param feed: The RSS item XML element to use. + ''' + GEO_NS = 'http://www.georss.org/georss' if self.__point: @@ -14,9 +35,20 @@ def extend_rss(self, entry): return entry + def extend_rss(self, entry): + return self.extend_file(entry) + def extend_atom(self, entry): - return self.extend_rss(self, entry) + return self.extend_file(entry) def point(self, point=None): - self.__point = point or '0.0 0.0' + '''Get or set the georss:point of the entry. + + :param point: The GeoRSS formatted point (i.e. "42.36 -71.05") + :returns: The author of the podcast. + ''' + + if point is not None: + self.__point = point + return self.__point diff --git a/tests/test_extension.py b/tests/test_extension.py index d717e20..d8568fd 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -134,6 +134,29 @@ def test_podcastEntryItems(self): namespaces=ns) assert author == ['Lars Kiesow'] +class TestExtensionGeo(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('geo') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_geoEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.point('42.36 -71.05') + + assert fe.geo.point() == '42.36 -71.05' + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + point = root.xpath('/rss/channel/item/georss:point/text()', + namespaces=ns) + assert point == ['42.36 -71.05'] + class TestExtensionDc(unittest.TestCase): From 8a65719dc4b330b6d3e59facbe392ef2acadea45 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 14 Oct 2017 19:33:14 +0200 Subject: [PATCH 29/96] Add Link To Entry Example This improves the feed entry example so that it will generate a valid feed entry. This fixes #63 --- readme.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.rst b/readme.rst index 85600c9..2abbf03 100644 --- a/readme.rst +++ b/readme.rst @@ -107,6 +107,7 @@ FeedEntry object:: >>> fe = fg.add_entry() >>> fe.id('http://lernfunk.de/media/654321/1') >>> fe.title('The First Episode') + >>> fe.link(href="http://lernfunk.de/feed") The FeedGenerators method `add_entry(...)` without argument provides will automatically generate a new FeedEntry object, append it to the feeds internal From 281765fefd5bd32d0df0f21a8f752a227c311e44 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 14 Oct 2017 19:24:16 +0200 Subject: [PATCH 30/96] Add Permalink Attribute To RSS GUID The `isPermalink` attribute can now be set using the `guid()` function for entries. Note that this only effects RSS feeds. This patch does not change Atom feeds in any way. This fixes #60 --- feedgen/entry.py | 25 +++++++++++++++---------- tests/test_entry.py | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 7527bf3..879c1cc 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -48,7 +48,7 @@ def __init__(self): self.__rss_description = None self.__rss_content = None self.__rss_enclosure = None - self.__rss_guid = None + self.__rss_guid = {} self.__rss_link = None self.__rss_pubDate = None self.__rss_source = None @@ -207,10 +207,11 @@ def rss_entry(self, extensions=True): for a in self.__rss_author or []: author = etree.SubElement(entry, 'author') author.text = a - if self.__rss_guid: + if self.__rss_guid.get('guid'): guid = etree.SubElement(entry, 'guid') - guid.text = self.__rss_guid - guid.attrib['isPermaLink'] = 'false' + guid.text = self.__rss_guid['guid'] + permaLink = str(self.__rss_guid.get('permalink', False)).lower() + guid.attrib['isPermaLink'] = permaLink for cat in self.__rss_category or []: category = etree.SubElement(entry, 'category') category.text = cat['value'] @@ -252,25 +253,29 @@ 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. + points in time. This method will also set rss:guid with permalink set + to False. 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 + self.__rss_guid = {'guid': id, 'permalink': False} return self.__atom_id - def guid(self, guid=None): + def guid(self, guid=None, permalink=False): '''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. + :param permalink: If this is a permanent identifier for this item + :returns: Id and permalink setting of the entry. ''' - return self.id(guid) + if guid is not None: + self.__atom_id = guid + self.__rss_guid = {'guid': guid, 'permalink': permalink} + return self.__rss_guid def updated(self, updated=None): '''Set or get the updated value which indicates the last time the entry diff --git a/tests/test_entry.py b/tests/test_entry.py index 2b986cf..57605f4 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -75,7 +75,7 @@ def test_TestEntryItems(self): assert link.get('href') == 'http://lkiesow.de' assert link.get('rel') == 'alternate' fe.guid('123') - assert fe.guid() == '123' + assert fe.guid().get('guid') == '123' fe.updated('2017-02-05 13:26:58+01:00') assert fe.updated().year == 2017 fe.summary('asdf') From ebb44be9b200cb2d7baf55315732eb7857557416 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 14 Oct 2017 18:51:40 +0200 Subject: [PATCH 31/96] Make Author's Name Optional RSS does not require an author's name, but only his email address. This patch makes the name optional for RSS. Note that the name is required in ATOM feeds and an author will not be included if the name is missing. This fixes #59 --- feedgen/entry.py | 7 +++++-- tests/test_entry.py | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 7527bf3..6810843 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -333,11 +333,14 @@ def author(self, author=None, replace=False, **kwargs): self.__atom_author = [] self.__atom_author += ensure_format(author, set(['name', 'email', 'uri']), - set(['name'])) + set()) self.__rss_author = [] for a in self.__atom_author: if a.get('email'): - self.__rss_author.append('%(email)s (%(name)s)' % a) + if a.get('name'): + self.__rss_author.append('%(email)s (%(name)s)' % a) + else: + self.__rss_author.append('%(email)s' % a) return self.__atom_author def content(self, content=None, src=None, type=None): diff --git a/tests/test_entry.py b/tests/test_entry.py index 2b986cf..6b56880 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -63,7 +63,11 @@ def test_TestEntryItems(self): fe = self.fg.add_item() fe.title('qwe') assert fe.title() == 'qwe' - author = fe.author(name='John Doe', email='jdoe@example.com')[0] + author = fe.author(email='ldoe@example.com')[0] + assert not author.get('name') + assert author.get('email') == 'ldoe@example.com' + author = fe.author(name='John Doe', email='jdoe@example.com', + replace=True)[0] assert author.get('name') == 'John Doe' assert author.get('email') == 'jdoe@example.com' contributor = fe.contributor(name='John Doe', email='jdoe@ex.com')[0] From 07073ec0b7a3283a9e59c804d18a77ffb70a2b77 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 14 Oct 2017 21:32:32 +0200 Subject: [PATCH 32/96] Release 0.6.0 - MediaRSS support - New entries are now rendered first - Python 3.6 support and testing - Add permalink attribute to RSS guid - Lots of tests and minor fixes --- feedgen/version.py | 2 +- python-feedgen.spec | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index b8d9f89..0ead690 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 5, 1) +version = (0, 6, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 2bdd7cd..8ab6a26 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.5.1 +Version: 0.6.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -93,6 +93,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* Sat Oct 14 2017 Lars Kiesow - 0.6.0-1 +- Update to 0.6.0 + * Thu Jan 05 2017 Lars Kiesow - 0.5.1-1 - Update to 0.5.1 From c92a340f88cebadcf116ff844e68d77dd5a43fe4 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 14 Oct 2017 21:42:14 +0200 Subject: [PATCH 33/96] Release 0.6.1 - Fixed release issue --- feedgen/version.py | 2 +- python-feedgen.spec | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 0ead690..b1a013b 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 6, 0) +version = (0, 6, 1) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 8ab6a26..bacf415 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.6.0 +Version: 0.6.1 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -93,6 +93,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* Sat Oct 14 2017 Lars Kiesow - 0.6.1-1 +- Update to 0.6.1 + * Sat Oct 14 2017 Lars Kiesow - 0.6.0-1 - Update to 0.6.0 From 917278b5688d60885ba21b6e92acf6bd14d98e5d Mon Sep 17 00:00:00 2001 From: Lumir Balhar Date: Wed, 25 Oct 2017 16:54:56 +0200 Subject: [PATCH 34/96] New specfile approved for Fedora --- python-feedgen.spec | 182 ++++++++++++++------------------------------ 1 file changed, 58 insertions(+), 124 deletions(-) diff --git a/python-feedgen.spec b/python-feedgen.spec index bacf415..b5fcb14 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,149 +1,83 @@ -%define srcname feedgen +%global pypi_name feedgen -Name: python-%{srcname} +Name: python-%{pypi_name} Version: 0.6.1 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) -Group: Development/Libraries -License: LGPLv3+ or BSD -URL: http://lkiesow.github.io/%{name}/ - -Source0: https://pypi.python.org/packages/source/f/%{srcname}/%{srcname}-%{version}.tar.gz - -BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) - - +License: BSD or LGPLv3 +URL: http://lkiesow.github.io/python-feedgen +Source0: https://github.com/lkiesow/%{name}/archive/v%{version}.tar.gz BuildArch: noarch + +BuildRequires: python2-dateutil BuildRequires: python2-devel -BuildRequires: python-setuptools +BuildRequires: python2-lxml +BuildRequires: python2-setuptools + +BuildRequires: python3-dateutil BuildRequires: python3-devel +BuildRequires: python3-lxml BuildRequires: python3-setuptools -Requires: python-lxml -Requires: python-dateutil - %description -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. - - -%package -n python3-%{srcname} -Summary: Feed Generator (ATOM, RSS, Podcasts) -Group: Development/Libraries - -Requires: python3-lxml +Feedgenerator 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. + +%package -n python2-%{pypi_name} +Summary: %{summary} +%{?python_provide:%python_provide python2-%{pypi_name}} + +Requires: python2-dateutil +Requires: python2-lxml +%description -n python2-%{pypi_name} +Feedgenerator 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. + +%package -n python3-%{pypi_name} +Summary: %{summary} +%{?python_provide:%python_provide python3-%{pypi_name}} + Requires: python3-dateutil - -%description -n python3-%{srcname} -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. +Requires: python3-lxml +%description -n python3-%{pypi_name} +Feedgenerator 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. %prep -%setup -q -n %{srcname}-%{version} -mkdir python2 -mv PKG-INFO docs feedgen license.bsd license.lgpl readme.rst setup.py python2 -cp -r python2 python3 - -# ensure the right python version is used -find python3 -name '*.py' | xargs sed -i '1s|^#!python|#!%{__python3}|' -find python2 -name '*.py' | xargs sed -i '1s|^#!python|#!%{__python2}|' - +%autosetup +# Remove bundled egg-info +rm -rf %{pypi_name}.egg-info %build -pushd python2 -%{__python2} setup.py build -popd -pushd python3 -%{__python3} setup.py build -popd - +%py2_build +%py3_build %install -rm -rf $RPM_BUILD_ROOT -pushd python3 -%{__python3} setup.py install -O1 --skip-build --root $RPM_BUILD_ROOT -popd -pushd python2 -%{__python2} setup.py install -O1 --skip-build --root $RPM_BUILD_ROOT -popd -chmod 644 $RPM_BUILD_ROOT%{python3_sitelib}/%{srcname}/*.py -chmod 644 $RPM_BUILD_ROOT%{python2_sitelib}/%{srcname}/*.py - - -%clean -rm -rf $RPM_BUILD_ROOT +%py2_install +%py3_install -%files -%defattr(-,root,root,-) -%license python2/license.* -%doc python2/docs/* -%{python2_sitelib}/* +%check +%{__python2} setup.py test +%{__python3} setup.py test +%files -n python2-%{pypi_name} +%license license.lgpl license.bsd +%doc readme.rst +%{python2_sitelib}/%{pypi_name} +%{python2_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info -%files -n python3-%{srcname} -%defattr(-,root,root,-) -%license python3/license.* -%doc python3/docs/* -%{python3_sitelib}/* - +%files -n python3-%{pypi_name} +%license license.lgpl license.bsd +%doc readme.rst +%{python3_sitelib}/%{pypi_name} +%{python3_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info %changelog -* Sat Oct 14 2017 Lars Kiesow - 0.6.1-1 -- Update to 0.6.1 - -* Sat Oct 14 2017 Lars Kiesow - 0.6.0-1 -- Update to 0.6.0 - -* Thu Jan 05 2017 Lars Kiesow - 0.5.1-1 -- Update to 0.5.1 - -* Thu Jan 05 2017 Lars Kiesow - 0.5.0-1 -- Update to 0.5.0 - -* Thu Jan 05 2017 Lars Kiesow - 0.4.1-1 -- Update to 0.4.1 - -* Sun Sep 04 2016 Lars Kiesow - 0.4.0-1 -- Update to 0.4.0 - -* Thu Oct 29 2015 Lars Kiesow - 0.3.2-1 -- Update to 0.3.2 - -* Mon May 4 2015 Lars Kiesow - 0.3.1-2 -- Building for Python 3 as well - -* Fri Jan 16 2015 Lars Kiesow - 0.3.1-1 -- Update to 0.3.1 - -* Sun Jul 20 2014 Lars Kiesow - 0.3.0-1 -- Update to 0.3 - -* Wed Jan 1 2014 Lars Kiesow - 0.2.8-1 -- Update to 0.2.8 - - -* Wed Jan 1 2014 Lars Kiesow - 0.2.7-1 -- Update to 0.2.7 - -* Mon Sep 23 2013 Lars Kiesow - 0.2.6-1 -- Update to 0.2.6 - -* Mon Jul 22 2013 Lars Kiesow - 0.2.5-1 -- Updated to 0.2.5-1 - -* Thu May 16 2013 Lars Kiesow - 0.2.4-1 -- Update to 0.2.4 - -* Tue May 14 2013 Lars Kiesow - 0.2.3-1 -- Update to 0.2.3 - -* Sun May 5 2013 Lars Kiesow - 0.2.2-1 -- Update to version 0.2.2 - -* Sat May 4 2013 Lars Kiesow - 0.1-1 -- Initial build +* Tue Oct 24 2017 Lumir Balhar - 0.6.1-1 +- Initial package. From 6aead0a7ac9566eebd741f9412a6a42387cc8d15 Mon Sep 17 00:00:00 2001 From: Arne Neumann Date: Fri, 27 Oct 2017 12:44:37 +0200 Subject: [PATCH 35/96] added order parameter to add_entry() Commit 770603320032a6afc7e82b27bcb4926468fa22d8 changed the order in which new entries are added to a feed. This commit allows the user to choose the order herself. --- feedgen/feed.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 6bf55d9..cb65d4f 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -995,12 +995,15 @@ def webMaster(self, webMaster=None): self.__rss_webMaster = webMaster return self.__rss_webMaster - def add_entry(self, feedEntry=None): + def add_entry(self, feedEntry=None, order='prepend'): '''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. + :param order: If `prepend` is chosen, the entry will be inserted + at the beginning of the feed. If `append` is chosen, + the entry will be appended to the feed. (default: `prepend`). :returns: FeedEntry object created or passed to this function. Example:: @@ -1030,7 +1033,10 @@ def add_entry(self, feedEntry=None): except ImportError: pass - self.__feed_entries.insert(0, feedEntry) + if order == 'prepend': + self.__feed_entries.insert(0, feedEntry) + else: + self.__feed_entries.append(feedEntry) return feedEntry def add_item(self, item=None): From 95d6fd3065d73d5d0516a616838177b5a9e91ca3 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sun, 4 Mar 2018 09:52:55 +0000 Subject: [PATCH 36/96] Correct spelling mistakes. --- feedgen/entry.py | 4 ++-- feedgen/ext/torrent.py | 2 +- feedgen/feed.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index d773e17..9bcd133 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -301,8 +301,8 @@ def updated(self, updated=None): 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 + '''Get or set author data. An author element is a dict 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: diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index a2eb49c..101f015 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -24,7 +24,7 @@ def extend_ns(self): class TorrentEntryExtension(BaseEntryExtension): - '''FeedEntry extention for torrent feeds + '''FeedEntry extension for torrent feeds ''' def __init__(self): self.__torrent_filename = None diff --git a/feedgen/feed.py b/feedgen/feed.py index 6bf55d9..39f8225 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -863,8 +863,8 @@ def managingEditor(self, managingEditor=None): 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. + :param managingEditor: Email address of the managing editor. + :returns: Email address of the managing editor. ''' if managingEditor is not None: self.__rss_managingEditor = managingEditor @@ -998,7 +998,7 @@ def webMaster(self, webMaster=None): 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. + is the preferred way to add new entries to a feed. :param feedEntry: FeedEntry object to add. :returns: FeedEntry object created or passed to this function. From 9026316391b8edb6a5ccb07a72ad39808751b35f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Mar 2018 20:05:29 +0100 Subject: [PATCH 37/96] Use isort to sort import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …so that they all have the same structure. --- feedgen/__main__.py | 3 ++- feedgen/compat.py | 1 + feedgen/entry.py | 6 ++++-- feedgen/ext/dc.py | 1 + feedgen/ext/media.py | 3 ++- feedgen/ext/podcast.py | 3 ++- feedgen/ext/podcast_entry.py | 1 + feedgen/ext/syndication.py | 1 + feedgen/ext/torrent.py | 3 ++- feedgen/feed.py | 11 ++++++----- setup.py | 1 + tests/test_entry.py | 1 + tests/test_extension.py | 4 +++- tests/test_feed.py | 4 +++- tests/test_main.py | 1 + 15 files changed, 31 insertions(+), 13 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 2cf1346..abc0737 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -8,9 +8,10 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from feedgen.feed import FeedGenerator import sys +from feedgen.feed import FeedGenerator + USAGE = ''' Usage: python -m feedgen [OPTION] diff --git a/feedgen/compat.py b/feedgen/compat.py index 80b6cd6..e9044b0 100644 --- a/feedgen/compat.py +++ b/feedgen/compat.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import sys + if sys.version_info[0] >= 3: string_types = str else: diff --git a/feedgen/entry.py b/feedgen/entry.py index d773e17..f65dafa 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -8,12 +8,14 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree from datetime import datetime + import dateutil.parser import dateutil.tz -from feedgen.util import ensure_format, formatRFC2822 +from lxml import etree + from feedgen.compat import string_types +from feedgen.util import ensure_format, formatRFC2822 class FeedEntry(object): diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index 184a29f..21a5244 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -14,6 +14,7 @@ ''' from lxml import etree + from feedgen.ext.base import BaseExtension diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py index 0bd5845..25d561a 100644 --- a/feedgen/ext/media.py +++ b/feedgen/ext/media.py @@ -11,8 +11,9 @@ ''' from lxml import etree + +from feedgen.ext.base import BaseEntryExtension, BaseExtension from feedgen.util import ensure_format -from feedgen.ext.base import BaseExtension, BaseEntryExtension MEDIA_NS = 'http://search.yahoo.com/mrss/' diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index e2bb3e5..a8af118 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -11,9 +11,10 @@ ''' from lxml import etree + +from feedgen.compat import string_types from feedgen.ext.base import BaseExtension from feedgen.util import ensure_format -from feedgen.compat import string_types class PodcastExtension(BaseExtension): diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 3f3a7d9..4fa6128 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -11,6 +11,7 @@ ''' from lxml import etree + from feedgen.ext.base import BaseEntryExtension diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py index 3b17e0c..0141369 100644 --- a/feedgen/ext/syndication.py +++ b/feedgen/ext/syndication.py @@ -11,6 +11,7 @@ ''' from lxml import etree + from feedgen.ext.base import BaseExtension SYNDICATION_NS = 'http://purl.org/rss/1.0/modules/syndication/' diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index a2eb49c..6f8b5f3 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -11,7 +11,8 @@ ''' from lxml import etree -from feedgen.ext.base import BaseExtension, BaseEntryExtension + +from feedgen.ext.base import BaseEntryExtension, BaseExtension TORRENT_NS = 'http://xmlns.ezrss.it/0.1/dtd/' diff --git a/feedgen/feed.py b/feedgen/feed.py index 6bf55d9..5539fb7 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -9,16 +9,17 @@ ''' -from lxml import etree +import sys from datetime import datetime + import dateutil.parser import dateutil.tz -from feedgen.entry import FeedEntry -from feedgen.util import ensure_format, formatRFC2822 +from lxml import etree + import feedgen.version -import sys from feedgen.compat import string_types - +from feedgen.entry import FeedEntry +from feedgen.util import ensure_format, formatRFC2822 _feedgen_version = feedgen.version.version_str diff --git a/setup.py b/setup.py index 2bfafc2..1549284 100755 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from setuptools import setup + import feedgen.version packages = ['feedgen', 'feedgen/ext'] diff --git a/tests/test_entry.py b/tests/test_entry.py index bb2d699..173eabf 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -7,6 +7,7 @@ """ import unittest + from feedgen.feed import FeedGenerator diff --git a/tests/test_extension.py b/tests/test_extension.py index bc4d335..59ef349 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -5,9 +5,11 @@ """ import unittest -from feedgen.feed import FeedGenerator + from lxml import etree +from feedgen.feed import FeedGenerator + class TestExtensionSyndication(unittest.TestCase): diff --git a/tests/test_feed.py b/tests/test_feed.py index ffd8e4b..fcdbd4a 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -10,9 +10,11 @@ import os import tempfile import unittest + from lxml import etree + +from feedgen.ext.dc import DcEntryExtension, DcExtension from feedgen.feed import FeedGenerator -from feedgen.ext.dc import DcExtension, DcEntryExtension class TestSequenceFunctions(unittest.TestCase): diff --git a/tests/test_main.py b/tests/test_main.py index a6c6b88..7985289 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -8,6 +8,7 @@ import sys import tempfile import unittest + from feedgen import __main__ From dd15d63fbc3698e28cea05e308bfa9dc69ce7fb9 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Mar 2018 20:10:01 +0100 Subject: [PATCH 38/96] Fix Travis Builds This patch fixes the Travis builds which were broken due to new Flake8 versions which complained about previously accepted code. --- feedgen/util.py | 10 +++++----- tests/test_main.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/feedgen/util.py b/feedgen/util.py index e361651..ca4ad58 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -64,11 +64,11 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): return val -def formatRFC2822(d): +def formatRFC2822(date): '''Make sure the locale setting do not interfere with the time format. ''' - l = locale.setlocale(locale.LC_ALL) + old = 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 + date = date.strftime('%a, %d %b %Y %H:%M:%S %z') + locale.setlocale(locale.LC_ALL, old) + return date diff --git a/tests/test_main.py b/tests/test_main.py index 7985289..efea777 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -27,7 +27,7 @@ def test_feed(self): sys.argv = ['feedgen', ftype] try: __main__.main() - except: + except Exception: assert False def test_file(self): @@ -36,7 +36,7 @@ def test_file(self): sys.argv = ['feedgen', filename] try: __main__.main() - except: + except Exception: assert False os.close(fh) os.remove(filename) From 45189d06ed1f089090397db6a8dd3017a1070af8 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Mar 2018 22:33:33 +0100 Subject: [PATCH 39/96] Fixed indentation of pull request #70 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch mixed tabs and spaces… --- feedgen/feed.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index cb921d0..14067b4 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -1004,7 +1004,8 @@ def add_entry(self, feedEntry=None, order='prepend'): :param feedEntry: FeedEntry object to add. :param order: If `prepend` is chosen, the entry will be inserted at the beginning of the feed. If `append` is chosen, - the entry will be appended to the feed. (default: `prepend`). + the entry will be appended to the feed. + (default: `prepend`). :returns: FeedEntry object created or passed to this function. Example:: @@ -1034,10 +1035,10 @@ def add_entry(self, feedEntry=None, order='prepend'): except ImportError: pass - if order == 'prepend': - self.__feed_entries.insert(0, feedEntry) - else: - self.__feed_entries.append(feedEntry) + if order == 'prepend': + self.__feed_entries.insert(0, feedEntry) + else: + self.__feed_entries.append(feedEntry) return feedEntry def add_item(self, item=None): From 27e9cd0c9a4dbf061f39d4b2ab7e48a63ee5308c Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Mar 2018 22:55:37 +0100 Subject: [PATCH 40/96] Fixed GeoRSS Extemsion This patch fixes a few issues with pzll request #66 which adds supprt for a simple GeoRSS extension. --- feedgen/ext/geo.py | 3 ++- feedgen/ext/geo_entry.py | 3 ++- tests/test_extension.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/feedgen/ext/geo.py b/feedgen/ext/geo.py index 1f31a2b..b6384d4 100644 --- a/feedgen/ext/geo.py +++ b/feedgen/ext/geo.py @@ -12,9 +12,10 @@ from feedgen.ext.base import BaseExtension + class GeoExtension(BaseExtension): '''FeedGenerator extension for Simple GeoRSS. ''' def extend_ns(self): - return { 'georss' : 'http://www.georss.org/georss' } + return {'georss': 'http://www.georss.org/georss'} diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index f57fe26..8c9dd15 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -13,6 +13,7 @@ from lxml import etree from feedgen.ext.base import BaseEntryExtension + class GeoEntryExtension(BaseEntryExtension): '''FeedEntry extension for Simple GeoRSS. ''' @@ -45,7 +46,7 @@ def point(self, point=None): '''Get or set the georss:point of the entry. :param point: The GeoRSS formatted point (i.e. "42.36 -71.05") - :returns: The author of the podcast. + :returns: The current georss:point of the entry. ''' if point is not None: diff --git a/tests/test_extension.py b/tests/test_extension.py index 2c1146d..db85c08 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -136,6 +136,7 @@ def test_podcastEntryItems(self): namespaces=ns) assert author == ['Lars Kiesow'] + class TestExtensionGeo(unittest.TestCase): def setUp(self): @@ -156,7 +157,7 @@ def test_geoEntryItems(self): ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) point = root.xpath('/rss/channel/item/georss:point/text()', - namespaces=ns) + namespaces=ns) assert point == ['42.36 -71.05'] From 7a053dc74cf7561f0be0d7722b843b5374e43b8d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 29 Apr 2018 21:11:30 +0200 Subject: [PATCH 41/96] Consistent method naming This patch renames the entries `pubdate` method to `pubDate` to be consistent with the feed's method as well as the documentation. Note that for now, the old method is preserved as well but is marked as deprecated and to be removed. This fixes #71 --- feedgen/entry.py | 13 +++++++++++++ tests/test_entry.py | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 8c83bbf..6de4c5a 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -12,6 +12,7 @@ import dateutil.parser import dateutil.tz +import warnings from lxml import etree from feedgen.compat import string_types @@ -569,11 +570,23 @@ def published(self, published=None): 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 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. + + pubdate(…) is deprected and may be removed in feedgen ≥ 0.8. Use + pubDate(…) instead. ''' + warnings.warn('pubdate(…) is deprected and may be removed in feedgen ' + '≥ 0.8. Use pubDate(…) instead.') return self.published(pubDate) def rights(self, rights=None): diff --git a/tests/test_entry.py b/tests/test_entry.py index 173eabf..1df3da5 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -87,8 +87,8 @@ def test_TestEntryItems(self): assert fe.summary() == 'asdf' fe.description('asdfx') assert fe.description() == 'asdfx' - fe.pubdate('2017-02-05 13:26:58+01:00') - assert fe.pubdate().year == 2017 + fe.pubDate('2017-02-05 13:26:58+01:00') + assert fe.pubDate().year == 2017 fe.rights('asdfx') assert fe.rights() == 'asdfx' fe.comments('asdfx') From 5b0a4049c651bd021ee643093e54f49a137037e2 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 19 May 2018 21:28:49 +0200 Subject: [PATCH 42/96] Disable sudo on Travis This patch disables sudo on Travis to enable faster builds. --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c0228e7..0061fdc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,7 @@ language: python +sudo: false + python: - "2.7" - "3.3" @@ -7,7 +9,7 @@ python: - "3.5" - "3.6" -before_install: +install: - pip install flake8 python-coveralls coverage - python setup.py bdist_wheel - pip install dist/feedgen* From 15dfb778700a6b888fe81bb19b4ea309593c7294 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 19 May 2018 21:47:28 +0200 Subject: [PATCH 43/96] Release 0.7.0 - New spec file based on Fedora packaging - Parameter to define the order of feed entries - Added GeoRSS extension - Fixed pubDate name --- feedgen/version.py | 2 +- python-feedgen.spec | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index b1a013b..1481720 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 6, 1) +version = (0, 7, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index b5fcb14..0170fd0 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %global pypi_name feedgen Name: python-%{pypi_name} -Version: 0.6.1 +Version: 0.7.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -10,12 +10,12 @@ URL: http://lkiesow.github.io/python-feedgen Source0: https://github.com/lkiesow/%{name}/archive/v%{version}.tar.gz BuildArch: noarch -BuildRequires: python2-dateutil +BuildRequires: python2-dateutil BuildRequires: python2-devel BuildRequires: python2-lxml BuildRequires: python2-setuptools -BuildRequires: python3-dateutil +BuildRequires: python3-dateutil BuildRequires: python3-devel BuildRequires: python3-lxml BuildRequires: python3-setuptools @@ -28,7 +28,7 @@ to produce Podcasts. %package -n python2-%{pypi_name} Summary: %{summary} %{?python_provide:%python_provide python2-%{pypi_name}} - + Requires: python2-dateutil Requires: python2-lxml %description -n python2-%{pypi_name} @@ -39,7 +39,7 @@ to produce Podcasts. %package -n python3-%{pypi_name} Summary: %{summary} %{?python_provide:%python_provide python3-%{pypi_name}} - + Requires: python3-dateutil Requires: python3-lxml %description -n python3-%{pypi_name} @@ -79,5 +79,8 @@ rm -rf %{pypi_name}.egg-info %{python3_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info %changelog +* Sat May 19 2018 Lars Kiesow - 0.7.0-1 +- Update to 0.7.0 + * Tue Oct 24 2017 Lumir Balhar - 0.6.1-1 - Initial package. From e3a3d053d45900de7bf8b9e9dd3b6d63072c6596 Mon Sep 17 00:00:00 2001 From: NoSuck Date: Sun, 3 Jun 2018 02:46:37 -0400 Subject: [PATCH 44/96] =?UTF-8?q?Missing=20=E2=80=9Cset=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thanks for taking time to make good documentation. --- feedgen/feed.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 14067b4..8c46c79 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -709,9 +709,9 @@ def contributor(self, contributor=None, replace=False, **kwargs): 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 + '''Get or set 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. From 9da6b17ca2f46dabf1f250847acbd4c4035c8c19 Mon Sep 17 00:00:00 2001 From: Unit 193 Date: Wed, 4 Jul 2018 03:29:15 -0400 Subject: [PATCH 45/96] Fix a few typos. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deprected → deprecated instanciate → instantiate repostiory → repository ressource → resource --- feedgen/__init__.py | 2 +- feedgen/entry.py | 4 ++-- feedgen/ext/dc.py | 2 +- readme.rst | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index ab98e93..35e7229 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -16,7 +16,7 @@ Create a Feed ------------- - To create a feed simply instanciate the FeedGenerator class and insert some + To create a feed simply instantiate the FeedGenerator class and insert some data:: >>> from feedgen.feed import FeedGenerator diff --git a/feedgen/entry.py b/feedgen/entry.py index 6de4c5a..a97758e 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -582,10 +582,10 @@ def pubdate(self, pubDate=None): was published. This method is just another name for the published(...) method. - pubdate(…) is deprected and may be removed in feedgen ≥ 0.8. Use + pubdate(…) is deprecated and may be removed in feedgen ≥ 0.8. Use pubDate(…) instead. ''' - warnings.warn('pubdate(…) is deprected and may be removed in feedgen ' + warnings.warn('pubdate(…) is deprecated and may be removed in feedgen ' '≥ 0.8. Use pubDate(…) instead.') return self.published(pubDate) diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index 21a5244..bc4cb7f 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -265,7 +265,7 @@ def dc_publisher(self, publisher=None, replace=False): return self._dcelem_publisher def dc_relation(self, relation=None, replace=False): - '''Get or set the dc:relation which describes a related ressource. + '''Get or set the dc:relation which describes a related resource. For more information see: http://dublincore.org/documents/dcmi-terms/#elements-relation diff --git a/readme.rst b/readme.rst index 2abbf03..cb8e685 100644 --- a/readme.rst +++ b/readme.rst @@ -32,7 +32,7 @@ Installation **Prebuild packages** If you are running Fedora Linux, RedHat Enterprise Linux, CentOS or Scientific -Linux you can use the RPM Copr repostiory: +Linux you can use the RPM Copr repository: http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/ @@ -56,7 +56,7 @@ You can also use pip to install the feedgen module. Simply run:: Create a Feed ------------- -To create a feed simply instanciate the FeedGenerator class and insert some +To create a feed simply instantiate the FeedGenerator class and insert some data:: >>> from feedgen.feed import FeedGenerator From cfaeb6889899b9622af72dd9d839950821feb164 Mon Sep 17 00:00:00 2001 From: daryl herzmann Date: Sat, 7 Jul 2018 21:27:22 -0500 Subject: [PATCH 46/96] allow CDATA content to go to RSS description This allows feed entry `content` specified with `type="CDATA"` to be translated to RSS description with CDATA specified --- feedgen/entry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index a97758e..c6080cd 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -206,7 +206,9 @@ def rss_entry(self, extensions=True): description.text = self.__rss_description elif self.__rss_content: description = etree.SubElement(entry, 'description') - description.text = self.__rss_content['content'] + description.text = etree.CDATA(self.__rss_content['content']) \ + if self.__rss_content.get('type', '') == 'CDATA' \ + else self.__rss_content['content'] for a in self.__rss_author or []: author = etree.SubElement(entry, 'author') author.text = a From 224e2a1959c5e6a7a856c6dab2f5f3c7eb3e45e2 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 12 Aug 2018 21:05:56 +0200 Subject: [PATCH 47/96] Update ATOM specification link --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 8c46c79..710aadc 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -32,7 +32,7 @@ def __init__(self): self.__feed_entries = [] # ATOM - # http://www.atomenabled.org/developers/syndication/ + # https://tools.ietf.org/html/rfc4287 # required self.__atom_id = None self.__atom_title = None From 1b301f67adf4e2f0367579a6c41f72ee524b9380 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 12 Aug 2018 21:07:15 +0200 Subject: [PATCH 48/96] Fix typo in docstring --- feedgen/entry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index c6080cd..d7827c8 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -604,8 +604,8 @@ def rights(self, rights=None): 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. + '''Get or set 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. From c6c7165a97fef4b2c9212640851ef227740b76b7 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 12 Aug 2018 21:07:47 +0200 Subject: [PATCH 49/96] Add Source Element to Feed Entries This patch implements the source element for feed entries/items. Note that only the set of RSS elements (URL and title) are implemented right now. ATOM supports additional elements which cannot be set at the moment. This fixes #79 --- feedgen/entry.py | 29 +++++++++++++++++++++++++++++ tests/test_entry.py | 3 +++ 2 files changed, 32 insertions(+) diff --git a/feedgen/entry.py b/feedgen/entry.py index d7827c8..92fb5f8 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -173,6 +173,15 @@ def atom_entry(self, extensions=True): rights = etree.SubElement(entry, 'rights') rights.text = self.__atom_rights + if self.__atom_source: + source = etree.SubElement(entry, 'source') + if self.__atom_source.get('title'): + source_title = etree.SubElement(source, 'title') + source_title.text = self.__atom_source['title'] + if self.__atom_source.get('link'): + etree.SubElement(source, 'link', + href=self.__atom_source['link']) + if extensions: for ext in self.__extensions.values() or []: if ext.get('atom'): @@ -233,6 +242,10 @@ def rss_entry(self, extensions=True): if self.__rss_pubDate: pubDate = etree.SubElement(entry, 'pubDate') pubDate.text = formatRFC2822(self.__rss_pubDate) + if self.__rss_source: + source = etree.SubElement(entry, 'source', + url=self.__rss_source['url']) + source.text = self.__rss_source['title'] if extensions: for ext in self.__extensions.values() or []: @@ -614,6 +627,22 @@ def comments(self, comments=None): self.__rss_comments = comments return self.__rss_comments + def source(self, url=None, title=None): + '''Get or set the source for the current feed entry. + + Note that ATOM feeds support a lot more sub elements than title and URL + (which is what RSS supports) but these are currently not supported. + Patches are welcome. + + :param url: Link to the source. + :param title: Title of the linked resource + :returns: Source element as dictionaries. + ''' + if url is not None and title is not None: + self.__rss_source = {'url': url, 'title': title} + self.__atom_source = {'link': url, 'title': title} + return self.__rss_source + 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 diff --git a/tests/test_entry.py b/tests/test_entry.py index 1df3da5..6c9835a 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -91,6 +91,9 @@ def test_TestEntryItems(self): assert fe.pubDate().year == 2017 fe.rights('asdfx') assert fe.rights() == 'asdfx' + source = fe.source(url='https://example.com', title='Test') + assert source.get('title') == 'Test' + assert source.get('url') == 'https://example.com' fe.comments('asdfx') assert fe.comments() == 'asdfx' fe.enclosure(url='http://lkiesow.de', type='text/plain', length='1') From 9d6827deddf36dacbf67171dbd9920080491df9d Mon Sep 17 00:00:00 2001 From: Michael Scherer Date: Fri, 14 Dec 2018 14:50:36 -0500 Subject: [PATCH 50/96] Fix typo --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 92fb5f8..ab3e84b 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -367,7 +367,7 @@ def author(self, author=None, replace=False, **kwargs): 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 + '''Get or set the content 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 From 0871b48b38df45f2fc1f1fa5fb1ee545a9a35e78 Mon Sep 17 00:00:00 2001 From: Michael Scherer Date: Fri, 14 Dec 2018 15:17:32 -0500 Subject: [PATCH 51/96] Another typo --- feedgen/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 35e7229..624658e 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -99,7 +99,7 @@ both parameters is true which means that the extension would be used for both kinds of feeds. - **Example: Produceing a Podcast** + **Example: Producing a Podcast** One extension already provided is the podcast extension. A podcast is an RSS feed with some additional elements for ITunes. From 640231b23a1539f65aa6b09642a4f458d8931d49 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 14 Dec 2018 23:35:48 +0100 Subject: [PATCH 52/96] Drop Python 3.3 Python 3.3 reached end-of-life on 2017-09-29. Since it's now causing problems with Travis builds, this patch is dropping that version from builds. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 0061fdc..4c50301 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,9 +2,9 @@ language: python sudo: false +# https://devguide.python.org/#branchstatus python: - "2.7" - - "3.3" - "3.4" - "3.5" - "3.6" From 2beefb51266434de5293c1f1606b92bee7101f19 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 14 Dec 2018 23:55:36 +0100 Subject: [PATCH 53/96] Update Documentation This patch updates the wording on the main documentation page. --- readme.rst | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/readme.rst b/readme.rst index cb8e685..6558505 100644 --- a/readme.rst +++ b/readme.rst @@ -11,8 +11,9 @@ Feedgenerator :alt: Test Coverage Status -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. 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 @@ -159,20 +160,17 @@ To produce a podcast simply load the `podcast` extension:: >>> 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. +If the FeedGenerator class is used to load an extension, it is automatically +loaded for every feed entry as well. You can, however, load an extension for a +specific FeedEntry only by calling `load_extension(...)` on that entry. -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`. +Even if extensions are loaded, they can be temporarily disabled during the feed +generation by calling the generating method with the keyword argument +`extensions` set to `False`. **Custom Extensions** -If you want to load custom extension which are not part of the feedgen Python +If you want to load custom extensions which are not part of the feedgen package, you can use the method `register_extension` instead. You can directly pass the classes for the feed and the entry extension to this method meaning that you can define them everywhere. From 3b557d364acd70332707acb1fd5d96831ae2a863 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 12:30:44 +1000 Subject: [PATCH 54/96] Add IDE and gesting artifacts to the gitignore file --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index b6f6775..af64cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea/ venv *.pyc *.pyo @@ -10,3 +11,7 @@ feedgen/tests/tmp_Rssfeed.xml tmp_Atomfeed.xml tmp_Rssfeed.xml + +# testing artifacts +.coverage +*.egg-info/ From 642862bb2b5e6f9bf8b3dad6b1459d2cb17b32d1 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 12:31:34 +1000 Subject: [PATCH 55/96] Update simple GeoRSS to complete the specification Originally the georss entry only contained a simple point specification. Update to include: - other geometries (line, polygon and box) - additional properties (featuretypetag, relationshiptag, featurename) - elevation (elev, floor) - radius (radius) This also includes basic type checking with a value error for the elev, floor and radius tags. --- feedgen/ext/geo_entry.py | 173 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 8c9dd15..4721e0a 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -9,6 +9,7 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' +import numbers from lxml import etree from feedgen.ext.base import BaseEntryExtension @@ -19,8 +20,24 @@ class GeoEntryExtension(BaseEntryExtension): ''' def __init__(self): - # Simple GeoRSS tag + '''Simple GeoRSS tag''' + # geometries self.__point = None + self.__line = None + self.__polygon = None + self.__box = None + + # additional properties + self.__featuretypetag = None + self.__relationshiptag = None + self.__featurename = None + + # elevation + self.__elev = None + self.__floor = None + + # radius + self.__radius = None def extend_file(self, entry): '''Add additional fields to an RSS item. @@ -34,6 +51,42 @@ def extend_file(self, entry): point = etree.SubElement(entry, '{%s}point' % GEO_NS) point.text = self.__point + if self.__line: + line = etree.SubElement(entry, '{%s}line' % GEO_NS) + line.text = self.__line + + if self.__polygon: + polygon = etree.SubElement(entry, '{%s}polygon' % GEO_NS) + polygon.text = self.__polygon + + if self.__box: + box = etree.SubElement(entry, '{%s}box' % GEO_NS) + box.text = self.__box + + if self.__featuretypetag: + featuretypetag = etree.SubElement(entry, '{%s}featuretypetag' % GEO_NS) + featuretypetag.text = self.__featuretypetag + + if self.__relationshiptag: + relationshiptag = etree.SubElement(entry, '{%s}relationshiptag' % GEO_NS) + relationshiptag.text = self.__relationshiptag + + if self.__featurename: + featurename = etree.SubElement(entry, '{%s}featurename' % GEO_NS) + featurename.text = self.__featurename + + if self.__elev: + elevation = etree.SubElement(entry, '{%s}elev' % GEO_NS) + elevation.text = self.__elev + + if self.__floor: + floor = etree.SubElement(entry, '{%s}floor' % GEO_NS) + floor.text = self.__floor + + if self.__radius: + radius = etree.SubElement(entry, '{%s}radius' % GEO_NS) + radius.text = self.__radius + return entry def extend_rss(self, entry): @@ -53,3 +106,121 @@ def point(self, point=None): self.__point = point return self.__point + + def line(self, line=None): + '''Get or set the georss:line of the entry + + :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86") + :return: The current georss:line of the entry + ''' + if line is not None: + self.__line = line + + return self.__line + + def polygon(self, polygon=None): + '''Get or set the georss:polygon of the entry + + :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86 45.256 -110.45") + :return: The current georss:polygon of the entry + ''' + if polygon is not None: + self.__polygon = polygon + + return self.__polygon + + def box(self, box=None): + ''' + Get or set the georss:box of the entry + + :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039 -69.856") + :return: The current georss:box of the entry + ''' + if box is not None: + self.__box = box + + return self.__box + + def featuretypetag(self, featuretypetag): + ''' + Get or set the georss:featuretypetag of the entry + + :param featuretypetag: The GeoRSS feaaturertyptag (e.g. "city") + :return: The current georss:featurertypetag + ''' + if featuretypetag is not None: + self.__featuretypetag = featuretypetag + + return self.__featuretypetag + + def relationshiptag(self, relationshiptag): + ''' + Get or set the georss:relationshiptag of the entry + + :param relationshiptag: The GeoRSS relationshiptag (e.g. "is-centred-at") + :return: the current georss:relationshiptag + ''' + if relationshiptag is not None: + self.__relationshiptag = relationshiptag + + return self.__relationshiptag + + def featurename(self, featurename): + ''' + Get or set the georss:featurename of the entry + + :param featuretypetag: The GeoRSS featurename (e.g. "city") + :return: the current georss:featurename + ''' + if featurename is not None: + self.__featurename = featurename + + return self.__featurename + + def elev(self, elev): + ''' + Get or set the georss:elev of the entry + + :param elev: The GeoRSS elevation (e.g. 100.3) + :type elev: numbers.Number + :return: the current georss:elev + ''' + if elev is not None: + if not isinstance(elev, numbers.Number): + raise ValueError("elev tag must be numeric: {}".format(elev)) + + self.__elev = elev + + return self.__elev + + def floor(self, floor): + ''' + Get or set the georss:floor of the entry + + :param floor: The GeoRSS floor (e.g. 4) + :type floor: int + :return: the current georss:floor + ''' + if floor is not None: + if not isinstance(floor, int): + raise ValueError("floor tag must be int: {}".format(floor)) + + self.__floor = floor + + return self.__floor + + def radius(self, radius): + ''' + Get or set the georss:radius of the entry + + :param radius: The GeoRSS radius (e.g. 100.3) + :type radius: numbers.Number + :return: the current georss:radius + ''' + if radius is not None: + if not isinstance(radius, numbers.Number): + raise ValueError("radius tag must be numeric: {}".format(radius)) + + self.__radius = radius + + return self.__radius From 8d413f576f9d1c26f74365d3eb87b4b310953874 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 13:38:39 +1000 Subject: [PATCH 56/96] Add a geom_from_geo_interface method for GeoRSS Entry A standard way for different geometry libraries in Python to be interoperable is a `__geo_interface__` for the geometry (see the specification: https://gist.github.com/sgillies/2217756). This includes the shapely library, geometries from QGIS, and geometries in Esri's arcpy libraries for ArcGIS desktop and ArcGIS pro. To make it easier to generate a georss entry a simple method which does the conversion (of the supported geometries only) and sets the appropriate geometry type. This includes a custom error for the geometry being incompatible and a custom warning for a polygon with interior holes. This is done to store the geometries on the exception / warning if required for debugging. --- feedgen/ext/geo_entry.py | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 4721e0a..e57fc81 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -10,11 +10,48 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' import numbers +import warnings from lxml import etree from feedgen.ext.base import BaseEntryExtension +class GeoRSSPolygonInteriorWarning(Warning): + """ + Simple placeholder for warning about ignored polygon interiors. + + Stores the original geom on a ``geom`` attribute (if required warnings are + raised as errors). + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSPolygonInteriorWarning, self).__init__(*args, **kwargs) + + def __str__(self): + return '{:d} interiors of polygon ignored'.format( + len(self.geom.__geo_interface__['coordinates']) - 1 # ignore exterior in count + ) + +class GeoRSSGeometryError(ValueError): + """ + Subclass of ValueError for a GeoRSS geometry error + + Only some geometries are supported in Simple GeoRSS, so if not raise an + error. Offending geometry is stored on the ``geom`` attribute. + + """ + + def __init__(self, geom, *args, **kwargs): + self.geom = geom + super(GeoRSSGeometryError, self).__init__(*args, **kwargs) + + def __str__(self): + return "Geometry of type '{}' not in Point, Linestring or Polygon".format( + self.geom.__geo_interface__['type'] + ) + + class GeoEntryExtension(BaseEntryExtension): '''FeedEntry extension for Simple GeoRSS. ''' @@ -224,3 +261,62 @@ def radius(self, radius): self.__radius = radius return self.__radius + + def geom_from_geo_interface(self, geom): + ''' + Generate a georss geometry from some Python object with a + ``__geo_interface__`` property (see the `geo_interface specification by + Sean Gillies`_geointerface ) + + Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be easily + converted to GeoRSS: + + - Point + - LineString + - Polygon (if there are holes / donuts in the polygons a warning will be + generaated + + Other GeoJson types will raise a ``ValueError``. + + .. note:: The geometry is assumed to be x, y as longitude, latitude in + the WGS84 projection. + + .. _geointerface: https://gist.github.com/sgillies/2217756 + .. _geojson: https://geojson.org/ + + :param geom: Geometry object with a __geo_interface__ property + :return: the formatted GeoRSS geometry + ''' + geojson = geom.__geo_interface__ + + if geojson['type'] not in ('Point', 'LineString', 'Polygon'): + raise GeoRSSGeometryError(geom) + + if geojson['type'] == 'Point': + + coords = '%f %f'.format( + geojson['coordinates'][1], # latitude is y + geojson['coordinates'][0] + ) + return self.point(coords) + + elif geojson['type'] == 'LineString': + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'] + ) + return self.line(coords) + + elif geojson['type'] == 'Polygon': + + if len(geojson['coordinates']) > 1: + warnings.warn(GeoRSSPolygonInteriorWarning(geom)) + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + geojson['coordinates'][0] + ) + return self.polygon(coords) From d32487f2edad0120c7eb4efc86ff53caa78ef114 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 13:49:56 +1000 Subject: [PATCH 57/96] Separate extensions as their own files Give each extension its own test file. Primarily this is done to make it easier to add some fixtures and extend the geo tests. --- tests/test_extension.py | 300 ---------------------- tests/test_extensions/__init__.py | 0 tests/test_extensions/test_dc.py | 31 +++ tests/test_extensions/test_geo.py | 29 +++ tests/test_extensions/test_media.py | 83 ++++++ tests/test_extensions/test_podcast.py | 96 +++++++ tests/test_extensions/test_syndication.py | 40 +++ tests/test_extensions/test_torrent.py | 38 +++ 8 files changed, 317 insertions(+), 300 deletions(-) delete mode 100644 tests/test_extension.py create mode 100644 tests/test_extensions/__init__.py create mode 100644 tests/test_extensions/test_dc.py create mode 100644 tests/test_extensions/test_geo.py create mode 100644 tests/test_extensions/test_media.py create mode 100644 tests/test_extensions/test_podcast.py create mode 100644 tests/test_extensions/test_syndication.py create mode 100644 tests/test_extensions/test_torrent.py diff --git a/tests/test_extension.py b/tests/test_extension.py deleted file mode 100644 index db85c08..0000000 --- a/tests/test_extension.py +++ /dev/null @@ -1,300 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Tests for extensions -""" - -import unittest - -from lxml import etree - -from feedgen.feed import FeedGenerator - - -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') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_update_period(self): - 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=self.SYN_NS) - assert a[0].text == period_type - - def test_update_frequency(self): - for frequency in (1, 100, 2000, 100000): - self.fg.syndication.update_frequency(frequency) - root = etree.fromstring(self.fg.rss_str()) - a = root.xpath('/rss/channel/sy:UpdateFrequency', - 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=self.SYN_NS) - assert a[0].text == base - - -class TestExtensionPodcast(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('podcast') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_category_new(self): - 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'} - 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) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' - - def test_category(self): - self.fg.podcast.itunes_category('Technology', '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'} - 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) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' - - def test_podcastItems(self): - fg = self.fg - fg.podcast.itunes_author('Lars Kiesow') - fg.podcast.itunes_block('x') - fg.podcast.itunes_complete(False) - fg.podcast.itunes_explicit('no') - fg.podcast.itunes_image('x.png') - fg.podcast.itunes_subtitle('x') - fg.podcast.itunes_summary('x') - assert fg.podcast.itunes_author() == 'Lars Kiesow' - assert fg.podcast.itunes_block() == 'x' - assert fg.podcast.itunes_complete() == 'no' - assert fg.podcast.itunes_explicit() == 'no' - assert fg.podcast.itunes_image() == 'x.png' - assert fg.podcast.itunes_subtitle() == 'x' - assert fg.podcast.itunes_summary() == 'x' - - # Check that we have the item in the resulting XML - ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} - root = etree.fromstring(self.fg.rss_str()) - author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) - assert author == ['Lars Kiesow'] - - def test_podcastEntryItems(self): - fe = self.fg.add_item() - fe.title('y') - fe.podcast.itunes_author('Lars Kiesow') - fe.podcast.itunes_block('x') - fe.podcast.itunes_duration('00:01:30') - fe.podcast.itunes_explicit('no') - fe.podcast.itunes_image('x.png') - fe.podcast.itunes_is_closed_captioned('yes') - fe.podcast.itunes_order(1) - fe.podcast.itunes_subtitle('x') - fe.podcast.itunes_summary('x') - assert fe.podcast.itunes_author() == 'Lars Kiesow' - assert fe.podcast.itunes_block() == 'x' - assert fe.podcast.itunes_duration() == '00:01:30' - assert fe.podcast.itunes_explicit() == 'no' - assert fe.podcast.itunes_image() == 'x.png' - assert fe.podcast.itunes_is_closed_captioned() - assert fe.podcast.itunes_order() == 1 - assert fe.podcast.itunes_subtitle() == 'x' - assert fe.podcast.itunes_summary() == 'x' - - # Check that we have the item in the resulting XML - ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} - root = etree.fromstring(self.fg.rss_str()) - author = root.xpath('/rss/channel/item/itunes:author/text()', - namespaces=ns) - assert author == ['Lars Kiesow'] - - -class TestExtensionGeo(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('geo') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_geoEntryItems(self): - fe = self.fg.add_item() - fe.title('y') - fe.geo.point('42.36 -71.05') - - assert fe.geo.point() == '42.36 -71.05' - - # Check that we have the item in the resulting XML - ns = {'georss': 'http://www.georss.org/georss'} - root = etree.fromstring(self.fg.rss_str()) - point = root.xpath('/rss/channel/item/georss:point/text()', - namespaces=ns) - assert point == ['42.36 -71.05'] - - -class TestExtensionDc(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('dc') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_entryLoadExtension(self): - fe = self.fg.add_item() - try: - fe.load_extension('dc') - except ImportError: - pass # Extension already loaded - - def test_elements(self): - for method in dir(self.fg.dc): - if method.startswith('dc_'): - m = getattr(self.fg.dc, method) - m(method) - assert m() == [method] - - self.fg.id('123') - assert self.fg.atom_str() - assert self.fg.rss_str() - - -class TestExtensionTorrent(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('torrent') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_podcastEntryItems(self): - fe = self.fg.add_item() - fe.title('y') - fe.torrent.filename('file.xy') - fe.torrent.infohash('123') - fe.torrent.contentlength('23') - fe.torrent.seeds('1') - fe.torrent.peers('2') - fe.torrent.verified('1') - assert fe.torrent.filename() == 'file.xy' - assert fe.torrent.infohash() == '123' - assert fe.torrent.contentlength() == '23' - assert fe.torrent.seeds() == '1' - assert fe.torrent.peers() == '2' - assert fe.torrent.verified() == '1' - - # Check that we have the item in the resulting XML - ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} - root = etree.fromstring(self.fg.rss_str()) - filename = root.xpath('/rss/channel/item/torrent:filename/text()', - namespaces=ns) - assert filename == ['file.xy'] - - -class TestExtensionMedia(unittest.TestCase): - - def setUp(self): - self.fg = FeedGenerator() - self.fg.load_extension('media') - self.fg.id('id') - self.fg.title('title') - self.fg.link(href='http://example.com', rel='self') - self.fg.description('description') - - def test_media_content(self): - fe = self.fg.add_item() - fe.id('id') - fe.title('title') - fe.content('content') - fe.media.content(url='file1.xy') - fe.media.content(url='file2.xy') - fe.media.content(url='file1.xy', group=2) - fe.media.content(url='file2.xy', group=2) - fe.media.content(url='file.xy', group=None) - - ns = {'media': 'http://search.yahoo.com/mrss/', - 'a': 'http://www.w3.org/2005/Atom'} - # Check that we have the item in the resulting RSS - root = etree.fromstring(self.fg.rss_str()) - url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - # There is one without a group - url = root.xpath('/rss/channel/item/media:content[1]/@url', - namespaces=ns) - assert url == ['file.xy'] - - # Check that we have the item in the resulting Atom feed - root = etree.fromstring(self.fg.atom_str()) - url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - fe.media.content(content=[], replace=True) - assert fe.media.content() == [] - - def test_media_thumbnail(self): - fe = self.fg.add_item() - fe.id('id') - fe.title('title') - fe.content('content') - fe.media.thumbnail(url='file1.xy') - fe.media.thumbnail(url='file2.xy') - fe.media.thumbnail(url='file1.xy', group=2) - fe.media.thumbnail(url='file2.xy', group=2) - fe.media.thumbnail(url='file.xy', group=None) - - ns = {'media': 'http://search.yahoo.com/mrss/', - 'a': 'http://www.w3.org/2005/Atom'} - # Check that we have the item in the resulting RSS - root = etree.fromstring(self.fg.rss_str()) - url = root.xpath( - '/rss/channel/item/media:group/media:thumbnail[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - # There is one without a group - url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', - namespaces=ns) - assert url == ['file.xy'] - - # Check that we have the item in the resulting Atom feed - root = etree.fromstring(self.fg.atom_str()) - url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', - namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] - - fe.media.thumbnail(thumbnail=[], replace=True) - assert fe.media.thumbnail() == [] diff --git a/tests/test_extensions/__init__.py b/tests/test_extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_extensions/test_dc.py b/tests/test_extensions/test_dc.py new file mode 100644 index 0000000..1623804 --- /dev/null +++ b/tests/test_extensions/test_dc.py @@ -0,0 +1,31 @@ +import unittest + +from feedgen.feed import FeedGenerator + + +class TestExtensionDc(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('dc') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_entryLoadExtension(self): + fe = self.fg.add_item() + try: + fe.load_extension('dc') + except ImportError: + pass # Extension already loaded + + def test_elements(self): + for method in dir(self.fg.dc): + if method.startswith('dc_'): + m = getattr(self.fg.dc, method) + m(method) + assert m() == [method] + + self.fg.id('123') + assert self.fg.atom_str() + assert self.fg.rss_str() diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py new file mode 100644 index 0000000..6665a74 --- /dev/null +++ b/tests/test_extensions/test_geo.py @@ -0,0 +1,29 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionGeo(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('geo') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_geoEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.point('42.36 -71.05') + + assert fe.geo.point() == '42.36 -71.05' + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + point = root.xpath('/rss/channel/item/georss:point/text()', + namespaces=ns) + assert point == ['42.36 -71.05'] diff --git a/tests/test_extensions/test_media.py b/tests/test_extensions/test_media.py new file mode 100644 index 0000000..7fd9e40 --- /dev/null +++ b/tests/test_extensions/test_media.py @@ -0,0 +1,83 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionMedia(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('media') + self.fg.id('id') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_media_content(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.content(url='file1.xy') + fe.media.content(url='file2.xy') + fe.media.content(url='file1.xy', group=2) + fe.media.content(url='file2.xy', group=2) + fe.media.content(url='file.xy', group=None) + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + # There is one without a group + url = root.xpath('/rss/channel/item/media:content[1]/@url', + namespaces=ns) + assert url == ['file.xy'] + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + fe.media.content(content=[], replace=True) + assert fe.media.content() == [] + + def test_media_thumbnail(self): + fe = self.fg.add_item() + fe.id('id') + fe.title('title') + fe.content('content') + fe.media.thumbnail(url='file1.xy') + fe.media.thumbnail(url='file2.xy') + fe.media.thumbnail(url='file1.xy', group=2) + fe.media.thumbnail(url='file2.xy', group=2) + fe.media.thumbnail(url='file.xy', group=None) + + ns = {'media': 'http://search.yahoo.com/mrss/', + 'a': 'http://www.w3.org/2005/Atom'} + # Check that we have the item in the resulting RSS + root = etree.fromstring(self.fg.rss_str()) + url = root.xpath( + '/rss/channel/item/media:group/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + # There is one without a group + url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file.xy'] + + # Check that we have the item in the resulting Atom feed + root = etree.fromstring(self.fg.atom_str()) + url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', + namespaces=ns) + assert url == ['file1.xy', 'file1.xy'] + + fe.media.thumbnail(thumbnail=[], replace=True) + assert fe.media.thumbnail() == [] diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py new file mode 100644 index 0000000..a41c96c --- /dev/null +++ b/tests/test_extensions/test_podcast.py @@ -0,0 +1,96 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionPodcast(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('podcast') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_category_new(self): + 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'} + 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) + assert cat[0] == 'Technology' + assert scat[0] == 'Podcasting' + + def test_category(self): + self.fg.podcast.itunes_category('Technology', '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'} + 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) + assert cat[0] == 'Technology' + assert scat[0] == 'Podcasting' + + def test_podcastItems(self): + fg = self.fg + fg.podcast.itunes_author('Lars Kiesow') + fg.podcast.itunes_block('x') + fg.podcast.itunes_complete(False) + fg.podcast.itunes_explicit('no') + fg.podcast.itunes_image('x.png') + fg.podcast.itunes_subtitle('x') + fg.podcast.itunes_summary('x') + assert fg.podcast.itunes_author() == 'Lars Kiesow' + assert fg.podcast.itunes_block() == 'x' + assert fg.podcast.itunes_complete() == 'no' + assert fg.podcast.itunes_explicit() == 'no' + assert fg.podcast.itunes_image() == 'x.png' + assert fg.podcast.itunes_subtitle() == 'x' + assert fg.podcast.itunes_summary() == 'x' + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) + assert author == ['Lars Kiesow'] + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.podcast.itunes_author('Lars Kiesow') + fe.podcast.itunes_block('x') + fe.podcast.itunes_duration('00:01:30') + fe.podcast.itunes_explicit('no') + fe.podcast.itunes_image('x.png') + fe.podcast.itunes_is_closed_captioned('yes') + fe.podcast.itunes_order(1) + fe.podcast.itunes_subtitle('x') + fe.podcast.itunes_summary('x') + assert fe.podcast.itunes_author() == 'Lars Kiesow' + assert fe.podcast.itunes_block() == 'x' + assert fe.podcast.itunes_duration() == '00:01:30' + assert fe.podcast.itunes_explicit() == 'no' + assert fe.podcast.itunes_image() == 'x.png' + assert fe.podcast.itunes_is_closed_captioned() + assert fe.podcast.itunes_order() == 1 + assert fe.podcast.itunes_subtitle() == 'x' + assert fe.podcast.itunes_summary() == 'x' + + # Check that we have the item in the resulting XML + ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} + root = etree.fromstring(self.fg.rss_str()) + author = root.xpath('/rss/channel/item/itunes:author/text()', + namespaces=ns) + assert author == ['Lars Kiesow'] diff --git a/tests/test_extensions/test_syndication.py b/tests/test_extensions/test_syndication.py new file mode 100644 index 0000000..7a187d7 --- /dev/null +++ b/tests/test_extensions/test_syndication.py @@ -0,0 +1,40 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +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') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_update_period(self): + 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=self.SYN_NS) + assert a[0].text == period_type + + def test_update_frequency(self): + for frequency in (1, 100, 2000, 100000): + self.fg.syndication.update_frequency(frequency) + root = etree.fromstring(self.fg.rss_str()) + a = root.xpath('/rss/channel/sy:UpdateFrequency', + 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=self.SYN_NS) + assert a[0].text == base diff --git a/tests/test_extensions/test_torrent.py b/tests/test_extensions/test_torrent.py new file mode 100644 index 0000000..e996fde --- /dev/null +++ b/tests/test_extensions/test_torrent.py @@ -0,0 +1,38 @@ +import unittest + +from lxml import etree + +from feedgen.feed import FeedGenerator + + +class TestExtensionTorrent(unittest.TestCase): + + def setUp(self): + self.fg = FeedGenerator() + self.fg.load_extension('torrent') + self.fg.title('title') + self.fg.link(href='http://example.com', rel='self') + self.fg.description('description') + + def test_podcastEntryItems(self): + fe = self.fg.add_item() + fe.title('y') + fe.torrent.filename('file.xy') + fe.torrent.infohash('123') + fe.torrent.contentlength('23') + fe.torrent.seeds('1') + fe.torrent.peers('2') + fe.torrent.verified('1') + assert fe.torrent.filename() == 'file.xy' + assert fe.torrent.infohash() == '123' + assert fe.torrent.contentlength() == '23' + assert fe.torrent.seeds() == '1' + assert fe.torrent.peers() == '2' + assert fe.torrent.verified() == '1' + + # Check that we have the item in the resulting XML + ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} + root = etree.fromstring(self.fg.rss_str()) + filename = root.xpath('/rss/channel/item/torrent:filename/text()', + namespaces=ns) + assert filename == ['file.xy'] From 8cd50bf768def7e50754496e7ed48a533ec38252 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 14:58:21 +1000 Subject: [PATCH 58/96] Add unit tests for simple GeoRSS Also fix a couple of bugs that came up during testing - mostly making sure that elevation, floor and radius are actually set as strings in the XML --- feedgen/ext/geo_entry.py | 20 +- tests/test_extensions/test_geo.py | 347 +++++++++++++++++++++++++++++- 2 files changed, 353 insertions(+), 14 deletions(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index e57fc81..03e8f41 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -114,15 +114,15 @@ def extend_file(self, entry): if self.__elev: elevation = etree.SubElement(entry, '{%s}elev' % GEO_NS) - elevation.text = self.__elev + elevation.text = str(self.__elev) if self.__floor: floor = etree.SubElement(entry, '{%s}floor' % GEO_NS) - floor.text = self.__floor + floor.text = str(self.__floor) if self.__radius: radius = etree.SubElement(entry, '{%s}radius' % GEO_NS) - radius.text = self.__radius + radius.text = str(self.__radius) return entry @@ -178,7 +178,7 @@ def box(self, box=None): return self.__box - def featuretypetag(self, featuretypetag): + def featuretypetag(self, featuretypetag=None): ''' Get or set the georss:featuretypetag of the entry @@ -190,7 +190,7 @@ def featuretypetag(self, featuretypetag): return self.__featuretypetag - def relationshiptag(self, relationshiptag): + def relationshiptag(self, relationshiptag=None): ''' Get or set the georss:relationshiptag of the entry @@ -202,11 +202,11 @@ def relationshiptag(self, relationshiptag): return self.__relationshiptag - def featurename(self, featurename): + def featurename(self, featurename=None): ''' Get or set the georss:featurename of the entry - :param featuretypetag: The GeoRSS featurename (e.g. "city") + :param featuretypetag: The GeoRSS featurename (e.g. "Footscray") :return: the current georss:featurename ''' if featurename is not None: @@ -214,7 +214,7 @@ def featurename(self, featurename): return self.__featurename - def elev(self, elev): + def elev(self, elev=None): ''' Get or set the georss:elev of the entry @@ -230,7 +230,7 @@ def elev(self, elev): return self.__elev - def floor(self, floor): + def floor(self, floor=None): ''' Get or set the georss:floor of the entry @@ -246,7 +246,7 @@ def floor(self, floor): return self.__floor - def radius(self, radius): + def radius(self, radius=None): ''' Get or set the georss:radius of the entry diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 6665a74..3855bf4 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -1,12 +1,104 @@ import unittest +import warnings from lxml import etree from feedgen.feed import FeedGenerator +from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError + + +class Geom(object): + """ + Dummy geom to make testing easier + + When we use the geo-interface we need a class with a `__geo_interface__` + property. Makes it easier for the other tests as well. + + Ultimately this could be used to generate dummy geometries for testing + a wider variety of values (e.g. with the faker library, or the hypothesis + library) + """ + + def __init__(self, geom_type, coords): + self.geom_type = geom_type + self.coords = coords + + def __str__(self): + if self.geom_type == 'Point': + + coords = '%f %f'.format( + self.coords[1], # latitude is y + self.coords[0] + ) + return coords + + elif self.geom_type == 'LineString': + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + self.coords + ) + return coords + + elif self.geom_type == 'Polygon': + + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + self.coords[0] + ) + return coords + + elif self.geom_type == 'Box': + # box not really supported by GeoJSON, but it's a handy cheat here + # for testing + coords = ' '.join( + '%f %f'.format(vertex[1], vertex[0]) + for vertex in + self.coords + ) + return coords[:2] + + else: + return 'Not a supported geometry' + + @property + def __geo_interface__(self): + return { + 'type': self.geom_type, + 'coordinates': self.coords + } class TestExtensionGeo(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.point = Geom('Point', [-71.05, 42.36]) + cls.line = Geom('LineString', [[-71.05, 42.36], [-71.15, 42.46]]) + cls.polygon = Geom('Polygon', [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]]) + cls.box = Geom('Box', [[-71.05, 42.36], [-71.15, 42.46]]) + cls.polygon_with_interior = Geom( + 'Polygon', + [ + [ # exterior + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0] + ], + [ # interior + [0.25, 0.25], + [0.25, 0.75], + [0.75, 0.75], + [0.75, 0.25], + [0.25, 0.25] + ] + ] + ) + def setUp(self): self.fg = FeedGenerator() self.fg.load_extension('geo') @@ -14,16 +106,263 @@ def setUp(self): self.fg.link(href='http://example.com', rel='self') self.fg.description('description') - def test_geoEntryItems(self): + def test_point(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.point(str(self.point)) + + self.assertEqual(fe.geo.point(), str(self.point)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + point = root.xpath('/rss/channel/item/georss:point/text()', + namespaces=ns) + self.assertEqual(point, [str(self.point)]) + + def test_line(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.line(str(self.line)) + + self.assertEqual(fe.geo.line(), str(self.line)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + line = root.xpath('/rss/channel/item/georss:line/text()', + namespaces=ns) + self.assertEqual(line, [str(self.line)]) + + def test_polygon(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.polygon(str(self.polygon)) + + self.assertEqual(fe.geo.polygon(), str(self.polygon)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon)]) + + def test_box(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.box(str(self.box)) + + self.assertEqual(fe.geo.box(), str(self.box)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + box = root.xpath('/rss/channel/item/georss:box/text()', + namespaces=ns) + self.assertEqual(box, [str(self.box)]) + + def test_featuretypetag(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.featuretypetag('city') + + self.assertEqual(fe.geo.featuretypetag(), 'city') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + featuretypetag = root.xpath( + '/rss/channel/item/georss:featuretypetag/text()', + namespaces=ns + ) + self.assertEqual(featuretypetag, ['city']) + + def test_relationshiptag(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.relationshiptag('is-centred-at') + + self.assertEqual(fe.geo.relationshiptag(), 'is-centred-at') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + relationshiptag = root.xpath( + '/rss/channel/item/georss:relationshiptag/text()', + namespaces=ns + ) + self.assertEqual(relationshiptag, ['is-centred-at']) + + def test_featurename(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.featurename('Footscray') + + self.assertEqual(fe.geo.featurename(), 'Footscray') + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + featurename = root.xpath( + '/rss/channel/item/georss:featurename/text()', + namespaces=ns + ) + self.assertEqual(featurename, ['Footscray']) + + def test_elev(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.elev(100.3) + + self.assertEqual(fe.geo.elev(), 100.3) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + elev = root.xpath( + '/rss/channel/item/georss:elev/text()', + namespaces=ns + ) + self.assertEqual(elev, ['100.3']) + + def test_elev_fails_nonnumeric(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.elev('100.3') + + def test_floor(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.floor(4) + + self.assertEqual(fe.geo.floor(), 4) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + floor = root.xpath( + '/rss/channel/item/georss:floor/text()', + namespaces=ns + ) + self.assertEqual(floor, ['4']) + + def test_floor_fails_nonint(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.floor(100.3) + + with self.assertRaises(ValueError): + fe.geo.floor('4') + + def test_radius(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.radius(100.3) + + self.assertEqual(fe.geo.radius(), 100.3) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + radius = root.xpath( + '/rss/channel/item/georss:radius/text()', + namespaces=ns + ) + self.assertEqual(radius, ['100.3']) + + def test_radius_fails_nonnumeric(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(ValueError): + fe.geo.radius('100.3') + + def test_geom_from_geointerface_point(self): fe = self.fg.add_item() fe.title('y') - fe.geo.point('42.36 -71.05') + fe.geo.geom_from_geo_interface(self.point) - assert fe.geo.point() == '42.36 -71.05' + self.assertEqual(fe.geo.point(), str(self.point)) # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) point = root.xpath('/rss/channel/item/georss:point/text()', namespaces=ns) - assert point == ['42.36 -71.05'] + self.assertEqual(point, [str(self.point)]) + + def test_geom_from_geointerface_line(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.geom_from_geo_interface(self.line) + + self.assertEqual(fe.geo.line(), str(self.line)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + line = root.xpath('/rss/channel/item/georss:line/text()', + namespaces=ns) + self.assertEqual(line, [str(self.line)]) + + def test_geom_from_geointerface_poly(self): + fe = self.fg.add_item() + fe.title('y') + fe.geo.geom_from_geo_interface(self.polygon) + + self.assertEqual(fe.geo.polygon(), str(self.polygon)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon)]) + + def test_geom_from_geointerface_fail_other_geom(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(GeoRSSGeometryError): + fe.geo.geom_from_geo_interface(self.box) + + def test_geom_from_geointerface_fail_requires_geo_interface(self): + fe = self.fg.add_item() + fe.title('y') + + with self.assertRaises(AttributeError): + fe.geo.geom_from_geo_interface(str(self.box)) + + + def test_geom_from_geointerface_warn_poly_interior(self): + """ + Test complex polygons warn as expected. Taken from + + https://stackoverflow.com/a/3892301/379566 and + https://docs.python.org/2.7/library/warnings.html#testing-warnings + """ + fe = self.fg.add_item() + fe.title('y') + + with warnings.catch_warnings(record=True) as w: + # Cause all warnings to always be triggered. + warnings.simplefilter("always") + # Trigger a warning. + fe.geo.geom_from_geo_interface(self.polygon_with_interior) + # Verify some things + assert len(w) == 1 + assert issubclass(w[-1].category, GeoRSSPolygonInteriorWarning) + + self.assertEqual(fe.geo.polygon(), str(self.polygon_with_interior)) + + # Check that we have the item in the resulting XML + ns = {'georss': 'http://www.georss.org/georss'} + root = etree.fromstring(self.fg.rss_str()) + poly = root.xpath('/rss/channel/item/georss:polygon/text()', + namespaces=ns) + self.assertEqual(poly, [str(self.polygon_with_interior)]) From b02278e536f09d19ceb93de0e196a00d06bb4af9 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 15:16:20 +1000 Subject: [PATCH 59/96] Fix a really dumb formatting issue for the geom_interface Use the old formatting tag instead of the new when creating geom text from the geo_interface. Tests updated as well --- feedgen/ext/geo_entry.py | 6 +++--- tests/test_extensions/test_geo.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 03e8f41..2fcc409 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -294,7 +294,7 @@ def geom_from_geo_interface(self, geom): if geojson['type'] == 'Point': - coords = '%f %f'.format( + coords = '{:f} {:f}'.format( geojson['coordinates'][1], # latitude is y geojson['coordinates'][0] ) @@ -303,7 +303,7 @@ def geom_from_geo_interface(self, geom): elif geojson['type'] == 'LineString': coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in geojson['coordinates'] ) @@ -315,7 +315,7 @@ def geom_from_geo_interface(self, geom): warnings.warn(GeoRSSPolygonInteriorWarning(geom)) coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in geojson['coordinates'][0] ) diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 3855bf4..35dfa84 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -26,7 +26,7 @@ def __init__(self, geom_type, coords): def __str__(self): if self.geom_type == 'Point': - coords = '%f %f'.format( + coords = '{:f} {:f}'.format( self.coords[1], # latitude is y self.coords[0] ) @@ -35,7 +35,7 @@ def __str__(self): elif self.geom_type == 'LineString': coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in self.coords ) @@ -44,7 +44,7 @@ def __str__(self): elif self.geom_type == 'Polygon': coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in self.coords[0] ) @@ -54,7 +54,7 @@ def __str__(self): # box not really supported by GeoJSON, but it's a handy cheat here # for testing coords = ' '.join( - '%f %f'.format(vertex[1], vertex[0]) + '{:f} {:f}'.format(vertex[1], vertex[0]) for vertex in self.coords ) From 9586e7bcf18de04d1d6b436340b11b7ea2f59609 Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 8 Jul 2019 15:39:33 +1000 Subject: [PATCH 60/96] Add a unit test to confirm all coordinates Make sure that all the required coordinates are in the GeoRSS string, to avoid the mistake made earlier. --- tests/test_extensions/test_geo.py | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 35dfa84..01990a6 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -1,3 +1,4 @@ +from itertools import chain import unittest import warnings @@ -296,6 +297,21 @@ def test_geom_from_geointerface_point(self): namespaces=ns) self.assertEqual(point, [str(self.point)]) + coords = [float(c) for c in point[0].split()] + + try: + self.assertCountEqual( + coords, + self.point.coords + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.point.coords)) + ) + + + def test_geom_from_geointerface_line(self): fe = self.fg.add_item() fe.title('y') @@ -310,6 +326,20 @@ def test_geom_from_geointerface_line(self): namespaces=ns) self.assertEqual(line, [str(self.line)]) + coords = [float(c) for c in line[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.line.coords)) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.line.coords)) + ) + + def test_geom_from_geointerface_poly(self): fe = self.fg.add_item() fe.title('y') @@ -324,6 +354,19 @@ def test_geom_from_geointerface_poly(self): namespaces=ns) self.assertEqual(poly, [str(self.polygon)]) + coords = [float(c) for c in poly[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.polygon.coords[0])) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.polygon.coords[0])) + ) + def test_geom_from_geointerface_fail_other_geom(self): fe = self.fg.add_item() fe.title('y') @@ -366,3 +409,16 @@ def test_geom_from_geointerface_warn_poly_interior(self): poly = root.xpath('/rss/channel/item/georss:polygon/text()', namespaces=ns) self.assertEqual(poly, [str(self.polygon_with_interior)]) + + coords = [float(c) for c in poly[0].split()] + + try: + self.assertCountEqual( + coords, + list(chain.from_iterable(self.polygon_with_interior.coords[0])) + ) + except AttributeError: # was assertItemsEqual in Python 2.7 + self.assertItemsEqual( + coords, + list(chain.from_iterable(self.polygon_with_interior.coords[0])) + ) From 66f8bdb45ec6973efca3c712678a88a4ef020a2e Mon Sep 17 00:00:00 2001 From: Henry Walshaw Date: Mon, 29 Jul 2019 10:24:02 +1000 Subject: [PATCH 61/96] Fix errors from make test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Python 2.7 error in the test (didn’t work for points) Also fixed all the formatting errors raised by flake8 --- feedgen/ext/geo_entry.py | 43 ++++++++++++++++++++----------- tests/test_extensions/test_geo.py | 35 ++++++++++++++----------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 2fcc409..2ad6611 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -30,8 +30,9 @@ def __init__(self, geom, *args, **kwargs): def __str__(self): return '{:d} interiors of polygon ignored'.format( - len(self.geom.__geo_interface__['coordinates']) - 1 # ignore exterior in count - ) + len(self.geom.__geo_interface__['coordinates']) - 1 + ) # ignore exterior in count + class GeoRSSGeometryError(ValueError): """ @@ -39,7 +40,6 @@ class GeoRSSGeometryError(ValueError): Only some geometries are supported in Simple GeoRSS, so if not raise an error. Offending geometry is stored on the ``geom`` attribute. - """ def __init__(self, geom, *args, **kwargs): @@ -47,7 +47,8 @@ def __init__(self, geom, *args, **kwargs): super(GeoRSSGeometryError, self).__init__(*args, **kwargs) def __str__(self): - return "Geometry of type '{}' not in Point, Linestring or Polygon".format( + msg = "Geometry of type '{}' not in Point, Linestring or Polygon" + return msg.format( self.geom.__geo_interface__['type'] ) @@ -101,11 +102,17 @@ def extend_file(self, entry): box.text = self.__box if self.__featuretypetag: - featuretypetag = etree.SubElement(entry, '{%s}featuretypetag' % GEO_NS) + featuretypetag = etree.SubElement( + entry, + '{%s}featuretypetag' % GEO_NS + ) featuretypetag.text = self.__featuretypetag if self.__relationshiptag: - relationshiptag = etree.SubElement(entry, '{%s}relationshiptag' % GEO_NS) + relationshiptag = etree.SubElement( + entry, + '{%s}relationshiptag' % GEO_NS + ) relationshiptag.text = self.__relationshiptag if self.__featurename: @@ -147,7 +154,8 @@ def point(self, point=None): def line(self, line=None): '''Get or set the georss:line of the entry - :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86") + :param point: The GeoRSS formatted line (i.e. "45.256 -110.45 46.46 + -109.48 43.84 -109.86") :return: The current georss:line of the entry ''' if line is not None: @@ -158,7 +166,8 @@ def line(self, line=None): def polygon(self, polygon=None): '''Get or set the georss:polygon of the entry - :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45 46.46 -109.48 43.84 -109.86 45.256 -110.45") + :param polygon: The GeoRSS formatted polygon (i.e. "45.256 -110.45 + 46.46 -109.48 43.84 -109.86 45.256 -110.45") :return: The current georss:polygon of the entry ''' if polygon is not None: @@ -170,7 +179,8 @@ def box(self, box=None): ''' Get or set the georss:box of the entry - :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039 -69.856") + :param box: The GeoRSS formatted box (i.e. "42.943 -71.032 43.039 + -69.856") :return: The current georss:box of the entry ''' if box is not None: @@ -194,7 +204,8 @@ def relationshiptag(self, relationshiptag=None): ''' Get or set the georss:relationshiptag of the entry - :param relationshiptag: The GeoRSS relationshiptag (e.g. "is-centred-at") + :param relationshiptag: The GeoRSS relationshiptag (e.g. + "is-centred-at") :return: the current georss:relationshiptag ''' if relationshiptag is not None: @@ -256,7 +267,9 @@ def radius(self, radius=None): ''' if radius is not None: if not isinstance(radius, numbers.Number): - raise ValueError("radius tag must be numeric: {}".format(radius)) + raise ValueError( + "radius tag must be numeric: {}".format(radius) + ) self.__radius = radius @@ -268,13 +281,13 @@ def geom_from_geo_interface(self, geom): ``__geo_interface__`` property (see the `geo_interface specification by Sean Gillies`_geointerface ) - Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be easily - converted to GeoRSS: + Note only a subset of GeoJSON (see `geojson.org`_geojson ) can be + easily converted to GeoRSS: - Point - LineString - - Polygon (if there are holes / donuts in the polygons a warning will be - generaated + - Polygon (if there are holes / donuts in the polygons a warning will + be generaated Other GeoJson types will raise a ``ValueError``. diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 01990a6..6dd401b 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -5,7 +5,7 @@ from lxml import etree from feedgen.feed import FeedGenerator -from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError +from feedgen.ext.geo_entry import GeoRSSPolygonInteriorWarning, GeoRSSGeometryError # noqa: E501 class Geom(object): @@ -78,19 +78,22 @@ class TestExtensionGeo(unittest.TestCase): def setUpClass(cls): cls.point = Geom('Point', [-71.05, 42.36]) cls.line = Geom('LineString', [[-71.05, 42.36], [-71.15, 42.46]]) - cls.polygon = Geom('Polygon', [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]]) + cls.polygon = Geom( + 'Polygon', + [[[-71.05, 42.36], [-71.15, 42.46], [-71.15, 42.36]]] + ) cls.box = Geom('Box', [[-71.05, 42.36], [-71.15, 42.46]]) cls.polygon_with_interior = Geom( 'Polygon', [ - [ # exterior + [ # exterior [0, 0], [0, 1], [1, 1], [1, 0], [0, 0] ], - [ # interior + [ # interior [0.25, 0.25], [0.25, 0.75], [0.75, 0.75], @@ -131,8 +134,10 @@ def test_line(self): # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) - line = root.xpath('/rss/channel/item/georss:line/text()', - namespaces=ns) + line = root.xpath( + '/rss/channel/item/georss:line/text()', + namespaces=ns + ) self.assertEqual(line, [str(self.line)]) def test_polygon(self): @@ -145,8 +150,10 @@ def test_polygon(self): # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) - poly = root.xpath('/rss/channel/item/georss:polygon/text()', - namespaces=ns) + poly = root.xpath( + '/rss/channel/item/georss:polygon/text()', + namespaces=ns + ) self.assertEqual(poly, [str(self.polygon)]) def test_box(self): @@ -159,8 +166,10 @@ def test_box(self): # Check that we have the item in the resulting XML ns = {'georss': 'http://www.georss.org/georss'} root = etree.fromstring(self.fg.rss_str()) - box = root.xpath('/rss/channel/item/georss:box/text()', - namespaces=ns) + box = root.xpath( + '/rss/channel/item/georss:box/text()', + namespaces=ns + ) self.assertEqual(box, [str(self.box)]) def test_featuretypetag(self): @@ -307,11 +316,9 @@ def test_geom_from_geointerface_point(self): except AttributeError: # was assertItemsEqual in Python 2.7 self.assertItemsEqual( coords, - list(chain.from_iterable(self.point.coords)) + self.point.coords ) - - def test_geom_from_geointerface_line(self): fe = self.fg.add_item() fe.title('y') @@ -339,7 +346,6 @@ def test_geom_from_geointerface_line(self): list(chain.from_iterable(self.line.coords)) ) - def test_geom_from_geointerface_poly(self): fe = self.fg.add_item() fe.title('y') @@ -381,7 +387,6 @@ def test_geom_from_geointerface_fail_requires_geo_interface(self): with self.assertRaises(AttributeError): fe.geo.geom_from_geo_interface(str(self.box)) - def test_geom_from_geointerface_warn_poly_interior(self): """ Test complex polygons warn as expected. Taken from From ca25295ac302ba501bc86c9670a895ff180333b3 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 22:22:41 +0200 Subject: [PATCH 62/96] Update Python Versions This patch drops tests of the rather dated Python 3.4 and 3.5 while simultaneously adding tests for Python 3.7 and updating the build and test environment. --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4c50301..717381a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,12 @@ language: python -sudo: false +dist: bionic # https://devguide.python.org/#branchstatus python: - "2.7" - - "3.4" - - "3.5" - "3.6" + - "3.7" install: - pip install flake8 python-coveralls coverage From 8e5e8845af213e271812bf58498a82579bc5dbab Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 22:45:27 +0200 Subject: [PATCH 63/96] Update Documentation - Update Fedora installation instructions - Use HTTPS for links --- readme.rst | 16 ++++------------ setup.py | 2 +- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/readme.rst b/readme.rst index 6558505..550739d 100644 --- a/readme.rst +++ b/readme.rst @@ -22,7 +22,7 @@ at license.bsd and license.lgpl. More details about the project: - Repository: https://github.com/lkiesow/python-feedgen -- Documentation: http://lkiesow.github.io/python-feedgen/ +- Documentation: https://lkiesow.github.io/python-feedgen/ - Python Package Index: https://pypi.python.org/pypi/feedgen/ @@ -32,18 +32,10 @@ Installation **Prebuild packages** -If you are running Fedora Linux, RedHat Enterprise Linux, CentOS or Scientific -Linux you can use the RPM Copr repository: +If your distribution includes this project as package, like Fedora Linux does, +you can simply use your package manager to install the package. For example:: -http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/ - -Simply enable the repository and run:: - - $ yum install python-feedgen - -or for the Python 3 package:: - - $ yum install python3-feedgen + $ dnf install python3-feedgen **Using pip** diff --git a/setup.py b/setup.py index 1549284..a5e2021 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ description='Feed Generator (ATOM, RSS, Podcasts)', author='Lars Kiesow', author_email='lkiesow@uos.de', - url='http://lkiesow.github.io/python-feedgen', + url='https://lkiesow.github.io/python-feedgen', keywords=['feed', 'ATOM', 'RSS', 'podcast'], license='FreeBSD and LGPLv3+', install_requires=['lxml', 'python-dateutil'], From b6a60c78835ad03e8bb849b5c4fa21bf430bc294 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 22:55:27 +0200 Subject: [PATCH 64/96] Add requirements.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c01ee4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +lxml==4.2.5 +python_dateutil==2.8.0 From ff236964ef9e60cc405a1a9f777126191823e969 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 11 Aug 2019 23:04:17 +0200 Subject: [PATCH 65/96] License Formatting --- license.bsd | 44 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/license.bsd b/license.bsd index a8e1879..beac4ea 100644 --- a/license.bsd +++ b/license.bsd @@ -1,31 +1,25 @@ +BSD 2-Clause License -Copyright 2011 Lars Kiesow. All rights reserved. -http://www.larskiesow.de +Copyright 2011, Lars Kiesow +All rights reserved. Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: +modification, are permitted provided that the following conditions are met: -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY -EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS -BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation -are those of the authors and should not be interpreted as representing -official policies, either expressed or implied, of everyone working on -this project. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 9e3146f2ab2e2701ec423e74d7e93865c39b5550 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 8 Sep 2019 12:20:13 +0200 Subject: [PATCH 66/96] Release 0.8.0 - Implement complete GeoRSS specification - Allow CDATA content in RSS description - Add source element to feed entries - Fixed a number of typos --- feedgen/version.py | 4 ++-- python-feedgen.spec | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 1481720..436c851 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -3,14 +3,14 @@ feedgen.version ~~~~~~~~~~~~~~~ - :copyright: 2013-2017, Lars Kiesow + :copyright: 2013-2018, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' 'Version of python-feedgen represented as tuple' -version = (0, 7, 0) +version = (0, 8, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 0170fd0..eac1fcb 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %global pypi_name feedgen Name: python-%{pypi_name} -Version: 0.7.0 +Version: 0.8.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) From edd988f8a6d0ba693a5ea494dae7311fdc7f1019 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 18 Oct 2019 23:56:36 +0200 Subject: [PATCH 67/96] Automated License Check This patch adds a license checker to the automated tests. This ensures that only dependencies with licenses from a list of known good licenses are used. --- .licenses.ini | 9 +++++++++ .travis.yml | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .licenses.ini diff --git a/.licenses.ini b/.licenses.ini new file mode 100644 index 0000000..cac5032 --- /dev/null +++ b/.licenses.ini @@ -0,0 +1,9 @@ +# Authorized licenses in lower case + +# There is no project rule against adding new licenses as long as they are +# compatible with the project's license. + +[Licenses] +authorized_licenses: + BSD + MIT diff --git a/.travis.yml b/.travis.yml index 717381a..652fae3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,12 +9,14 @@ python: - "3.7" install: - - pip install flake8 python-coveralls coverage + - pip install flake8 python-coveralls coverage liccheck + - pip install -r requirements.txt - python setup.py bdist_wheel - pip install dist/feedgen* script: - make test + - liccheck -s .licenses.ini - python -m feedgen - python -m feedgen atom - python -m feedgen rss From e7714888540fe82ddcc41d9754259d4de59e1759 Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Sun, 24 Nov 2019 12:44:00 -0500 Subject: [PATCH 68/96] Add support for html summaries for Atom feeds - Add a test - Update existing test - Make flake8 happy --- feedgen/entry.py | 88 ++++++++++++++++++++++++++------------------- tests/test_entry.py | 15 +++++++- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index ab3e84b..ea86581 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -19,6 +19,45 @@ from feedgen.util import ensure_format, formatRFC2822 +def _add_text_elm(entry, data, name): + """Add a text subelement to an entry""" + if not data: + return + + elm = etree.SubElement(entry, name) + type_ = data.get('type') + if data.get('src'): + if name != 'content': + raise ValueError("Only the 'content' element of an entry can " + "contain a 'src' attribute") + elm.attrib['src'] = data['src'] + elif data.get(name): + # Surround xhtml with a div tag, parse it and embed it + if type_ == 'xhtml': + elm.append(etree.fromstring( + '
' + + data.get(name) + '
')) + elif type_ == 'CDATA': + elm.text = etree.CDATA( + data.get(name)) + # Emed the text in escaped form + elif not type_ or type_.startswith('text') or type_ == 'html': + elm.text = data.get(name) + # Parse XML and embed it + elif type_.endswith('/xml') or type_.endswith('+xml'): + elm.append(etree.fromstring( + data[name])) + # Everything else should be included base64 encoded + else: + raise ValueError( + 'base64 encoded {} is not supported at the moment. ' + 'Pull requests adding support are welcome.'.format(name) + ) + # Add type description of the content + if type_: + elm.attrib['type'] = type_ + + class FeedEntry(object): '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item node. @@ -96,35 +135,7 @@ def atom_entry(self, extensions=True): 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 + _add_text_elm(entry, self.__atom_content, 'content') for l in self.__atom_link or []: link = etree.SubElement(entry, 'link', href=l['href']) @@ -139,9 +150,7 @@ def atom_entry(self, extensions=True): if l.get('length'): link.attrib['length'] = l['length'] - if self.__atom_summary: - summary = etree.SubElement(entry, 'summary') - summary.text = self.__atom_summary + _add_text_elm(entry, self.__atom_summary, 'summary') for c in self.__atom_category or []: cat = etree.SubElement(entry, 'category', term=c['term']) @@ -453,7 +462,7 @@ def link(self, link=None, replace=False, **kwargs): # return the set with more information (atom) return self.__atom_link - def summary(self, summary=None): + def summary(self, summary=None, type=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 @@ -467,11 +476,16 @@ def summary(self, summary=None): ''' 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: + # summary before. Not if it is the description. + if not self.__rss_description or ( + self.__atom_summary and + self.__rss_description == self.__atom_summary.get("summary") + ): self.__rss_description = summary - self.__atom_summary = summary + + self.__atom_summary = {'summary': summary} + if type is not None: + self.__atom_summary['type'] = type return self.__atom_summary def description(self, description=None, isSummary=False): diff --git a/tests/test_entry.py b/tests/test_entry.py index 6c9835a..5eaeffa 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -84,7 +84,7 @@ def test_TestEntryItems(self): fe.updated('2017-02-05 13:26:58+01:00') assert fe.updated().year == 2017 fe.summary('asdf') - assert fe.summary() == 'asdf' + assert fe.summary() == {'summary': 'asdf'} fe.description('asdfx') assert fe.description() == 'asdfx' fe.pubDate('2017-02-05 13:26:58+01:00') @@ -164,3 +164,16 @@ def test_content_cdata_type(self): fe.content('content', type='CDATA') result = fg.atom_str() assert b'' in result + + def test_summary_html_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.link(href='http://lernfunk.de/media/654322/1') + fe.summary('

summary

', type='html') + result = fg.atom_str() + expected = b'<p>summary</p>' + assert expected in result From d1e77c78ee43d4ade0a3aebe3183eb132137c82b Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Mon, 25 Nov 2019 23:19:41 -0500 Subject: [PATCH 69/96] Use more appropriate exception class for unimplemented functionality --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index ea86581..e90cbfd 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -49,7 +49,7 @@ def _add_text_elm(entry, data, name): data[name])) # Everything else should be included base64 encoded else: - raise ValueError( + raise NotImplementedError( 'base64 encoded {} is not supported at the moment. ' 'Pull requests adding support are welcome.'.format(name) ) From 26b64ca9fcac6b8e53132698b9fe0d4971993a4d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 02:11:39 +0100 Subject: [PATCH 70/96] Properly Parse text/xml This patch fixes the problem that content with the MIME type `text/xml` is accidentally treated as text rather than as XML. --- feedgen/entry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index e90cbfd..8617bb2 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -40,13 +40,13 @@ def _add_text_elm(entry, data, name): elif type_ == 'CDATA': elm.text = etree.CDATA( data.get(name)) - # Emed the text in escaped form - elif not type_ or type_.startswith('text') or type_ == 'html': - elm.text = data.get(name) # Parse XML and embed it - elif type_.endswith('/xml') or type_.endswith('+xml'): + elif type_ and (type_.endswith('/xml') or type_.endswith('+xml')): elm.append(etree.fromstring( data[name])) + # Embed the text in escaped form + elif not type_ or type_.startswith('text') or type_ == 'html': + elm.text = data.get(name) # Everything else should be included base64 encoded else: raise NotImplementedError( From 5a68e682d902a9d3b7f11dbf3f62ae8c623bb18f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 24 Jan 2020 17:51:37 +0100 Subject: [PATCH 71/96] Make Generator Optional (Atom) This patch makes the field generator optional in Atom, allowing to set an empty string to disable the element in the same way it is disabled in RSS already: ```python # disable generator element fg.generator('') ``` This fixes #89 --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 710aadc..b2a206f 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -165,7 +165,7 @@ def _create_atom(self, extensions=True): uri = etree.SubElement(contrib, 'uri') uri.text = c.get('uri') - if self.__atom_generator: + if self.__atom_generator and self.__atom_generator.get('value'): generator = etree.SubElement(feed, 'generator') generator.text = self.__atom_generator['value'] if self.__atom_generator.get('uri'): From 771b45021b35e58bddc3614164af9560ce1b03d4 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 21:39:55 +0100 Subject: [PATCH 72/96] Add Security Policy This patch adds a security policy stating how to handle and repost security issues found in this library. --- SECURITY.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7af7bb8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +Security Policy +=============== + +Supported Versions +------------------ + +Only the latest version of this library is supported. +We are doing our best to make updates as easy as possible +so that keeping up-to-date is usually pretty easy. + + +Reporting a Vulnerability +------------------------- + +If you find a security vulnerability, +please report it by sending a mail to security@lkiesow.de. +We will discuss the problem internally and, if necessary, release a patched version as soon as possible. From e942a0839ef6f5b2c4a1dbd6e84e428de179025f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 22:09:52 +0100 Subject: [PATCH 73/96] Documentation Update Just updating a few minor bits of the documentation. --- SECURITY.md => SECURITY.rst | 0 readme.rst | 118 ++++++++++++++++++++---------------- 2 files changed, 65 insertions(+), 53 deletions(-) rename SECURITY.md => SECURITY.rst (100%) diff --git a/SECURITY.md b/SECURITY.rst similarity index 100% rename from SECURITY.md rename to SECURITY.rst diff --git a/readme.rst b/readme.rst index 550739d..3aa2c8f 100644 --- a/readme.rst +++ b/readme.rst @@ -21,9 +21,9 @@ at license.bsd and license.lgpl. More details about the project: -- Repository: https://github.com/lkiesow/python-feedgen -- Documentation: https://lkiesow.github.io/python-feedgen/ -- Python Package Index: https://pypi.python.org/pypi/feedgen/ +- `Repository `_ +- `Documentation `_ +- `Python Package Index `_ ------------ @@ -50,18 +50,20 @@ Create a Feed ------------- To create a feed simply instantiate 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') +data: + +.. code-block:: python + + 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: @@ -70,22 +72,26 @@ feed you can use all of the following ways to provide data: - 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'}, ...]) +.. code-block:: python + + 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'}, ...]) ----------------- 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: + +.. code-block:: python - >>> 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 ---------------- @@ -95,31 +101,35 @@ 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:: +FeedEntry object: - >>> fe = fg.add_entry() - >>> fe.id('http://lernfunk.de/media/654321/1') - >>> fe.title('The First Episode') - >>> fe.link(href="http://lernfunk.de/feed") +.. code-block:: python -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. + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1') + fe.title('The First Episode') + fe.link(href="http://lernfunk.de/feed") + +The FeedGenerator's method `add_entry(...)` will generate a new FeedEntry +object, automatically append it to the feeds internal list of entries and +return it, so that additional data can be added. ---------- 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 extensions to include additional data into the XML +structure of the feeds. Extensions can be loaded like this: + +.. code-block:: python - >>> 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 example would 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 @@ -127,7 +137,7 @@ 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` control if the extension is used for ATOM and -RSS feeds, respectively. The default value for both parameters is `true` +RSS feeds respectively. The default value for both parameters is `True`, meaning the extension is used for both kinds of feeds. **Example: Producing a Podcast** @@ -135,22 +145,24 @@ meaning the extension is used for both kinds of feeds. 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: + +.. code-block:: python - >>> from feedgen.feed import FeedGenerator - >>> fg = FeedGenerator() - >>> fg.load_extension('podcast') + from feedgen.feed import FeedGenerator + fg = FeedGenerator() + fg.load_extension('podcast') ... - >>> fg.podcast.itunes_category('Technology', 'Podcasting') + fg.podcast.itunes_category('Technology', 'Podcasting') ... - >>> fe = fg.add_entry() - >>> fe.id('http://lernfunk.de/media/654321/1/file.mp3') - >>> fe.title('The First Episode') - >>> fe.description('Enjoy our first episode.') - >>> fe.enclosure('http://lernfunk.de/media/654321/1/file.mp3', 0, 'audio/mpeg') + fe = fg.add_entry() + fe.id('http://lernfunk.de/media/654321/1/file.mp3') + fe.title('The First Episode') + fe.description('Enjoy our first episode.') + fe.enclosure('http://lernfunk.de/media/654321/1/file.mp3', 0, 'audio/mpeg') ... - >>> fg.rss_str(pretty=True) - >>> fg.rss_file('podcast.xml') + fg.rss_str(pretty=True) + fg.rss_file('podcast.xml') If the FeedGenerator class is used to load an extension, it is automatically loaded for every feed entry as well. You can, however, load an extension for a From 9440ccaffe60259d7040306d0d97da9310b11d57 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 22:27:58 +0100 Subject: [PATCH 74/96] Update Python Versions This patch updates the Python versions to test against, dropping the now officially unsupported Python 2.7. --- .travis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 652fae3..80721da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,9 +4,9 @@ dist: bionic # https://devguide.python.org/#branchstatus python: - - "2.7" - - "3.6" - - "3.7" + - 3.6 + - 3.7 + - 3.8 install: - pip install flake8 python-coveralls coverage liccheck From 0eb12f9133ec2f977b88b0f8c07b3249b5b7051d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 25 Jan 2020 15:58:49 +0100 Subject: [PATCH 75/96] Prevent XML Denial of Service Attacks This patch prevents entity expansion for provided XML content to guard against XML denial of service attacks like XML bomb or Billion laughs attack. --- feedgen/entry.py | 93 ++++++++++++++-------------- feedgen/ext/dc.py | 13 ++-- feedgen/ext/geo_entry.py | 28 ++++----- feedgen/ext/media.py | 12 ++-- feedgen/ext/podcast.py | 33 +++++----- feedgen/ext/podcast_entry.py | 23 ++++--- feedgen/ext/syndication.py | 5 +- feedgen/ext/torrent.py | 18 +++--- feedgen/feed.py | 113 +++++++++++++++++------------------ feedgen/util.py | 22 +++++++ 10 files changed, 180 insertions(+), 180 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 8617bb2..66400ba 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -3,7 +3,7 @@ feedgen.entry ~~~~~~~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013-2020, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -13,10 +13,11 @@ import dateutil.parser import dateutil.tz import warnings -from lxml import etree + +from lxml.etree import CDATA # nosec - adding CDATA entry is safe from feedgen.compat import string_types -from feedgen.util import ensure_format, formatRFC2822 +from feedgen.util import ensure_format, formatRFC2822, xml_fromstring, xml_elem def _add_text_elm(entry, data, name): @@ -24,7 +25,7 @@ def _add_text_elm(entry, data, name): if not data: return - elm = etree.SubElement(entry, name) + elm = xml_elem(name, entry) type_ = data.get('type') if data.get('src'): if name != 'content': @@ -34,16 +35,14 @@ def _add_text_elm(entry, data, name): elif data.get(name): # Surround xhtml with a div tag, parse it and embed it if type_ == 'xhtml': - elm.append(etree.fromstring( - '
' + - data.get(name) + '
')) + xhtml = '
' \ + + data.get(name) + '
' + elm.append(xml_fromstring(xhtml)) elif type_ == 'CDATA': - elm.text = etree.CDATA( - data.get(name)) + elm.text = CDATA(data.get(name)) # Parse XML and embed it elif type_ and (type_.endswith('/xml') or type_.endswith('+xml')): - elm.append(etree.fromstring( - data[name])) + elm.append(xml_fromstring(data[name])) # Embed the text in escaped form elif not type_ or type_.startswith('text') or type_ == 'html': elm.text = data.get(name) @@ -102,14 +101,14 @@ def __init__(self): def atom_entry(self, extensions=True): '''Create an ATOM entry and return it.''' - entry = etree.Element('entry') + entry = xml_elem('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 = xml_elem('id', entry) id.text = self.__atom_id - title = etree.SubElement(entry, 'title') + title = xml_elem('title', entry) title.text = self.__atom_title - updated = etree.SubElement(entry, 'updated') + updated = xml_elem('updated', entry) updated.text = self.__atom_updated.isoformat() # An entry must contain an alternate link if there is no content @@ -125,20 +124,20 @@ def atom_entry(self, extensions=True): # Atom requires a name. Skip elements without. if not a.get('name'): continue - author = etree.SubElement(entry, 'author') - name = etree.SubElement(author, 'name') + author = xml_elem('author', entry) + name = xml_elem('name', author) name.text = a.get('name') if a.get('email'): - email = etree.SubElement(author, 'email') + email = xml_elem('email', author) email.text = a.get('email') if a.get('uri'): - uri = etree.SubElement(author, 'uri') + uri = xml_elem('uri', author) uri.text = a.get('uri') _add_text_elm(entry, self.__atom_content, 'content') for l in self.__atom_link or []: - link = etree.SubElement(entry, 'link', href=l['href']) + link = xml_elem('link', entry, href=l['href']) if l.get('rel'): link.attrib['rel'] = l['rel'] if l.get('type'): @@ -153,7 +152,7 @@ def atom_entry(self, extensions=True): _add_text_elm(entry, self.__atom_summary, 'summary') for c in self.__atom_category or []: - cat = etree.SubElement(entry, 'category', term=c['term']) + cat = xml_elem('category', entry, term=c['term']) if c.get('scheme'): cat.attrib['scheme'] = c['scheme'] if c.get('label'): @@ -164,32 +163,31 @@ def atom_entry(self, extensions=True): # Atom requires a name. Skip elements without. if not c.get('name'): continue - contrib = etree.SubElement(entry, 'contributor') - name = etree.SubElement(contrib, 'name') + contrib = xml_elem('contributor', entry) + name = xml_elem('name', contrib) name.text = c.get('name') if c.get('email'): - email = etree.SubElement(contrib, 'email') + email = xml_elem('email', contrib) email.text = c.get('email') if c.get('uri'): - uri = etree.SubElement(contrib, 'uri') + uri = xml_elem('uri', contrib) uri.text = c.get('uri') if self.__atom_published: - published = etree.SubElement(entry, 'published') + published = xml_elem('published', entry) published.text = self.__atom_published.isoformat() if self.__atom_rights: - rights = etree.SubElement(entry, 'rights') + rights = xml_elem('rights', entry) rights.text = self.__atom_rights if self.__atom_source: - source = etree.SubElement(entry, 'source') + source = xml_elem('source', entry) if self.__atom_source.get('title'): - source_title = etree.SubElement(source, 'title') + source_title = xml_elem('title', source) source_title.text = self.__atom_source['title'] if self.__atom_source.get('link'): - etree.SubElement(source, 'link', - href=self.__atom_source['link']) + xml_elem('link', source, href=self.__atom_source['link']) if extensions: for ext in self.__extensions.values() or []: @@ -200,60 +198,59 @@ def atom_entry(self, extensions=True): def rss_entry(self, extensions=True): '''Create a RSS item and return it.''' - entry = etree.Element('item') + entry = xml_elem('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 = xml_elem('title', entry) title.text = self.__rss_title if self.__rss_link: - link = etree.SubElement(entry, 'link') + link = xml_elem('link', entry) link.text = self.__rss_link if self.__rss_description and self.__rss_content: - description = etree.SubElement(entry, 'description') + description = xml_elem('description', entry) 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']) \ + content = xml_elem('{%s}encoded' % XMLNS_CONTENT, entry) + content.text = 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 = xml_elem('description', entry) description.text = self.__rss_description elif self.__rss_content: - description = etree.SubElement(entry, 'description') - description.text = etree.CDATA(self.__rss_content['content']) \ + description = xml_elem('description', entry) + description.text = CDATA(self.__rss_content['content']) \ if self.__rss_content.get('type', '') == 'CDATA' \ else self.__rss_content['content'] for a in self.__rss_author or []: - author = etree.SubElement(entry, 'author') + author = xml_elem('author', entry) author.text = a if self.__rss_guid.get('guid'): - guid = etree.SubElement(entry, 'guid') + guid = xml_elem('guid', entry) guid.text = self.__rss_guid['guid'] permaLink = str(self.__rss_guid.get('permalink', False)).lower() guid.attrib['isPermaLink'] = permaLink for cat in self.__rss_category or []: - category = etree.SubElement(entry, 'category') + category = xml_elem('category', entry) category.text = cat['value'] if cat.get('domain'): category.attrib['domain'] = cat['domain'] if self.__rss_comments: - comments = etree.SubElement(entry, 'comments') + comments = xml_elem('comments', entry) comments.text = self.__rss_comments if self.__rss_enclosure: - enclosure = etree.SubElement(entry, 'enclosure') + enclosure = xml_elem('enclosure', entry) 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 = xml_elem('pubDate', entry) pubDate.text = formatRFC2822(self.__rss_pubDate) if self.__rss_source: - source = etree.SubElement(entry, 'source', - url=self.__rss_source['url']) + source = xml_elem('source', entry, url=self.__rss_source['url']) source.text = self.__rss_source['title'] if extensions: diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index bc4cb7f..f731c0b 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -13,9 +13,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseExtension +from feedgen.util import xml_elem class DcBaseExtension(BaseExtension): @@ -45,10 +44,10 @@ def __init__(self): 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. + def _extend_xml(self, xml_element): + '''Extend xml_element with set DC fields. - :param xml_elem: etree element + :param xml_element: etree element ''' DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/' @@ -58,8 +57,8 @@ def _extend_xml(self, xml_elem): '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 = xml_elem('{%s}%s' % (DCELEMENTS_NS, elem), + xml_element) node.text = val def extend_atom(self, atom_feed): diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index 2ad6611..bb06cc2 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -12,8 +12,8 @@ import numbers import warnings -from lxml import etree from feedgen.ext.base import BaseEntryExtension +from feedgen.util import xml_elem class GeoRSSPolygonInteriorWarning(Warning): @@ -86,49 +86,43 @@ def extend_file(self, entry): GEO_NS = 'http://www.georss.org/georss' if self.__point: - point = etree.SubElement(entry, '{%s}point' % GEO_NS) + point = xml_elem('{%s}point' % GEO_NS, entry) point.text = self.__point if self.__line: - line = etree.SubElement(entry, '{%s}line' % GEO_NS) + line = xml_elem('{%s}line' % GEO_NS, entry) line.text = self.__line if self.__polygon: - polygon = etree.SubElement(entry, '{%s}polygon' % GEO_NS) + polygon = xml_elem('{%s}polygon' % GEO_NS, entry) polygon.text = self.__polygon if self.__box: - box = etree.SubElement(entry, '{%s}box' % GEO_NS) + box = xml_elem('{%s}box' % GEO_NS, entry) box.text = self.__box if self.__featuretypetag: - featuretypetag = etree.SubElement( - entry, - '{%s}featuretypetag' % GEO_NS - ) + featuretypetag = xml_elem('{%s}featuretypetag' % GEO_NS, entry) featuretypetag.text = self.__featuretypetag if self.__relationshiptag: - relationshiptag = etree.SubElement( - entry, - '{%s}relationshiptag' % GEO_NS - ) + relationshiptag = xml_elem('{%s}relationshiptag' % GEO_NS, entry) relationshiptag.text = self.__relationshiptag if self.__featurename: - featurename = etree.SubElement(entry, '{%s}featurename' % GEO_NS) + featurename = xml_elem('{%s}featurename' % GEO_NS, entry) featurename.text = self.__featurename if self.__elev: - elevation = etree.SubElement(entry, '{%s}elev' % GEO_NS) + elevation = xml_elem('{%s}elev' % GEO_NS, entry) elevation.text = str(self.__elev) if self.__floor: - floor = etree.SubElement(entry, '{%s}floor' % GEO_NS) + floor = xml_elem('{%s}floor' % GEO_NS, entry) floor.text = str(self.__floor) if self.__radius: - radius = etree.SubElement(entry, '{%s}radius' % GEO_NS) + radius = xml_elem('{%s}radius' % GEO_NS, entry) radius.text = str(self.__radius) return entry diff --git a/feedgen/ext/media.py b/feedgen/ext/media.py index 25d561a..74a5317 100644 --- a/feedgen/ext/media.py +++ b/feedgen/ext/media.py @@ -10,10 +10,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseEntryExtension, BaseExtension -from feedgen.util import ensure_format +from feedgen.util import ensure_format, xml_elem MEDIA_NS = 'http://search.yahoo.com/mrss/' @@ -45,10 +43,10 @@ def extend_atom(self, entry): # Define current media:group group = groups.get(media_content.get('group')) if group is None: - group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) + group = xml_elem('{%s}group' % MEDIA_NS, entry) groups[media_content.get('group')] = group # Add content - content = etree.SubElement(group, '{%s}content' % MEDIA_NS) + content = xml_elem('{%s}content' % MEDIA_NS, group) for attr in ('url', 'fileSize', 'type', 'medium', 'isDefault', 'expression', 'bitrate', 'framerate', 'samplingrate', 'channels', 'duration', 'height', 'width', 'lang'): @@ -59,10 +57,10 @@ def extend_atom(self, entry): # Define current media:group group = groups.get(media_thumbnail.get('group')) if group is None: - group = etree.SubElement(entry, '{%s}group' % MEDIA_NS) + group = xml_elem('{%s}group' % MEDIA_NS, entry) groups[media_thumbnail.get('group')] = group # Add thumbnails - thumbnail = etree.SubElement(group, '{%s}thumbnail' % MEDIA_NS) + thumbnail = xml_elem('{%s}thumbnail' % MEDIA_NS, group) for attr in ('url', 'height', 'width', 'time'): if media_thumbnail.get(attr): thumbnail.set(attr, media_thumbnail[attr]) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index a8af118..4c7eb0b 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -10,11 +10,9 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.compat import string_types from feedgen.ext.base import BaseExtension -from feedgen.util import ensure_format +from feedgen.util import ensure_format, xml_elem class PodcastExtension(BaseExtension): @@ -47,11 +45,11 @@ def extend_rss(self, rss_feed): channel = rss_feed[0] if self.__itunes_author: - author = etree.SubElement(channel, '{%s}author' % ITUNES_NS) + author = xml_elem('{%s}author' % ITUNES_NS, channel) author.text = self.__itunes_author if self.__itunes_block is not None: - block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) + block = xml_elem('{%s}block' % ITUNES_NS, channel) block.text = 'yes' if self.__itunes_block else 'no' for c in self.__itunes_category or []: @@ -60,45 +58,42 @@ def extend_rss(self, rss_feed): 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 = xml_elem('{%s}category' % ITUNES_NS, channel) category.attrib['text'] = c.get('cat') if c.get('sub'): - subcategory = etree.SubElement(category, - '{%s}category' % ITUNES_NS) + subcategory = xml_elem('{%s}category' % ITUNES_NS, category) subcategory.attrib['text'] = c.get('sub') if self.__itunes_image: - image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) + image = xml_elem('{%s}image' % ITUNES_NS, channel) image.attrib['href'] = self.__itunes_image if self.__itunes_explicit in ('yes', 'no', 'clean'): - explicit = etree.SubElement(channel, '{%s}explicit' % ITUNES_NS) + explicit = xml_elem('{%s}explicit' % ITUNES_NS, channel) explicit.text = self.__itunes_explicit if self.__itunes_complete in ('yes', 'no'): - complete = etree.SubElement(channel, '{%s}complete' % ITUNES_NS) + complete = xml_elem('{%s}complete' % ITUNES_NS, channel) 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 = xml_elem('{%s}new-feed-url' % ITUNES_NS, channel) 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 = xml_elem('{%s}owner' % ITUNES_NS, channel) + owner_name = xml_elem('{%s}name' % ITUNES_NS, owner) owner_name.text = self.__itunes_owner.get('name') - owner_email = etree.SubElement(owner, '{%s}email' % ITUNES_NS) + owner_email = xml_elem('{%s}email' % ITUNES_NS, owner) owner_email.text = self.__itunes_owner.get('email') if self.__itunes_subtitle: - subtitle = etree.SubElement(channel, '{%s}subtitle' % ITUNES_NS) + subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, channel) subtitle.text = self.__itunes_subtitle if self.__itunes_summary: - summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) + summary = xml_elem('{%s}summary' % ITUNES_NS, channel) summary.text = self.__itunes_summary return rss_feed diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 4fa6128..2a3771f 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -10,9 +10,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseEntryExtension +from feedgen.util import xml_elem class PodcastEntryExtension(BaseEntryExtension): @@ -40,43 +39,43 @@ def extend_rss(self, entry): ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' if self.__itunes_author: - author = etree.SubElement(entry, '{%s}author' % ITUNES_NS) + author = xml_elem('{%s}author' % ITUNES_NS, entry) author.text = self.__itunes_author if self.__itunes_block is not None: - block = etree.SubElement(entry, '{%s}block' % ITUNES_NS) + block = xml_elem('{%s}block' % ITUNES_NS, entry) block.text = 'yes' if self.__itunes_block else 'no' if self.__itunes_image: - image = etree.SubElement(entry, '{%s}image' % ITUNES_NS) + image = xml_elem('{%s}image' % ITUNES_NS, entry) image.attrib['href'] = self.__itunes_image if self.__itunes_duration: - duration = etree.SubElement(entry, '{%s}duration' % ITUNES_NS) + duration = xml_elem('{%s}duration' % ITUNES_NS, entry) duration.text = self.__itunes_duration if self.__itunes_explicit in ('yes', 'no', 'clean'): - explicit = etree.SubElement(entry, '{%s}explicit' % ITUNES_NS) + explicit = xml_elem('{%s}explicit' % ITUNES_NS, entry) explicit.text = self.__itunes_explicit if self.__itunes_is_closed_captioned is not None: - is_closed_captioned = etree.SubElement( - entry, '{%s}isClosedCaptioned' % ITUNES_NS) + is_closed_captioned = xml_elem( + '{%s}isClosedCaptioned' % ITUNES_NS, entry) if self.__itunes_is_closed_captioned: is_closed_captioned.text = 'yes' else: is_closed_captioned.text = 'no' if self.__itunes_order is not None and self.__itunes_order >= 0: - order = etree.SubElement(entry, '{%s}order' % ITUNES_NS) + order = xml_elem('{%s}order' % ITUNES_NS, entry) order.text = str(self.__itunes_order) if self.__itunes_subtitle: - subtitle = etree.SubElement(entry, '{%s}subtitle' % ITUNES_NS) + subtitle = xml_elem('{%s}subtitle' % ITUNES_NS, entry) subtitle.text = self.__itunes_subtitle if self.__itunes_summary: - summary = etree.SubElement(entry, '{%s}summary' % ITUNES_NS) + summary = xml_elem('{%s}summary' % ITUNES_NS, entry) summary.text = self.__itunes_summary return entry diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py index 0141369..016b144 100644 --- a/feedgen/ext/syndication.py +++ b/feedgen/ext/syndication.py @@ -10,9 +10,8 @@ http://web.resource.org/rss/1.0/modules/syndication/ ''' -from lxml import etree - from feedgen.ext.base import BaseExtension +from feedgen.util import xml_elem SYNDICATION_NS = 'http://purl.org/rss/1.0/modules/syndication/' PERIOD_TYPE = ('hourly', 'daily', 'weekly', 'monthly', 'yearly') @@ -20,7 +19,7 @@ def _set_value(channel, name, value): if value: - newelem = etree.SubElement(channel, '{%s}' % SYNDICATION_NS + name) + newelem = xml_elem('{%s}' % SYNDICATION_NS + name, channel) newelem.text = value diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index e26d0bb..5548a81 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -10,9 +10,8 @@ :license: FreeBSD and LGPL, see license.* for more details. ''' -from lxml import etree - from feedgen.ext.base import BaseEntryExtension, BaseExtension +from feedgen.util import xml_elem TORRENT_NS = 'http://xmlns.ezrss.it/0.1/dtd/' @@ -41,30 +40,29 @@ def extend_rss(self, entry): :param feed: The RSS item XML element to use. ''' if self.__torrent_filename: - filename = etree.SubElement(entry, '{%s}filename' % TORRENT_NS) + filename = xml_elem('{%s}filename' % TORRENT_NS, entry) filename.text = self.__torrent_filename if self.__torrent_contentlength: - contentlength = etree.SubElement(entry, - '{%s}contentlength' % TORRENT_NS) + contentlength = xml_elem('{%s}contentlength' % TORRENT_NS, entry) contentlength.text = self.__torrent_contentlength if self.__torrent_infohash: - infohash = etree.SubElement(entry, '{%s}infohash' % TORRENT_NS) + infohash = xml_elem('{%s}infohash' % TORRENT_NS, entry) infohash.text = self.__torrent_infohash - magnet = etree.SubElement(entry, '{%s}magneturi' % TORRENT_NS) + magnet = xml_elem('{%s}magneturi' % TORRENT_NS, entry) magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_infohash if self.__torrent_seeds: - seeds = etree.SubElement(entry, '{%s}seed' % TORRENT_NS) + seeds = xml_elem('{%s}seed' % TORRENT_NS, entry) seeds.text = self.__torrent_seeds if self.__torrent_peers: - peers = etree.SubElement(entry, '{%s}peers' % TORRENT_NS) + peers = xml_elem('{%s}peers' % TORRENT_NS, entry) peers.text = self.__torrent_peers if self.__torrent_verified: - verified = etree.SubElement(entry, '{%s}verified' % TORRENT_NS) + verified = xml_elem('{%s}verified' % TORRENT_NS, entry) verified.text = self.__torrent_verified def filename(self, torrent_filename=None): diff --git a/feedgen/feed.py b/feedgen/feed.py index b2a206f..9ebd219 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -3,7 +3,7 @@ feedgen.feed ~~~~~~~~~~~~ - :copyright: 2013-2016, Lars Kiesow + :copyright: 2013-2020, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. @@ -14,12 +14,12 @@ import dateutil.parser import dateutil.tz -from lxml import etree +from lxml import etree # nosec - not using this for parsing import feedgen.version from feedgen.compat import string_types from feedgen.entry import FeedEntry -from feedgen.util import ensure_format, formatRFC2822 +from feedgen.util import ensure_format, formatRFC2822, xml_elem _feedgen_version = feedgen.version.version_str @@ -47,7 +47,7 @@ def __init__(self): self.__atom_contributor = None self.__atom_generator = { 'value': 'python-feedgen', - 'uri': 'http://lkiesow.github.io/python-feedgen', + 'uri': 'https://lkiesow.github.io/python-feedgen', 'version': feedgen.version.version_str} # {value*,uri,version} self.__atom_icon = None self.__atom_logo = None @@ -95,9 +95,9 @@ def _create_atom(self, extensions=True): if ext.get('atom'): nsmap.update(ext['inst'].extend_ns()) - feed = etree.Element('feed', - xmlns='http://www.w3.org/2005/Atom', - nsmap=nsmap) + feed = xml_elem('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 @@ -108,11 +108,11 @@ def _create_atom(self, extensions=True): ([] if self.__atom_updated else ['updated']) missing = ', '.join(missing) raise ValueError('Required fields not set (%s)' % missing) - id = etree.SubElement(feed, 'id') + id = xml_elem('id', feed) id.text = self.__atom_id - title = etree.SubElement(feed, 'title') + title = xml_elem('title', feed) title.text = self.__atom_title - updated = etree.SubElement(feed, 'updated') + updated = xml_elem('updated', feed) updated.text = self.__atom_updated.isoformat() # Add author elements @@ -120,18 +120,18 @@ def _create_atom(self, extensions=True): # Atom requires a name. Skip elements without. if not a.get('name'): continue - author = etree.SubElement(feed, 'author') - name = etree.SubElement(author, 'name') + author = xml_elem('author', feed) + name = xml_elem('name', author) name.text = a.get('name') if a.get('email'): - email = etree.SubElement(author, 'email') + email = xml_elem('email', author) email.text = a.get('email') if a.get('uri'): - uri = etree.SubElement(author, 'uri') + uri = xml_elem('uri', author) uri.text = a.get('uri') for l in self.__atom_link or []: - link = etree.SubElement(feed, 'link', href=l['href']) + link = xml_elem('link', feed, href=l['href']) if l.get('rel'): link.attrib['rel'] = l['rel'] if l.get('type'): @@ -144,7 +144,7 @@ def _create_atom(self, extensions=True): link.attrib['length'] = l['length'] for c in self.__atom_category or []: - cat = etree.SubElement(feed, 'category', term=c['term']) + cat = xml_elem('category', feed, term=c['term']) if c.get('scheme'): cat.attrib['scheme'] = c['scheme'] if c.get('label'): @@ -155,18 +155,18 @@ def _create_atom(self, extensions=True): # Atom requires a name. Skip elements without. if not c.get('name'): continue - contrib = etree.SubElement(feed, 'contributor') - name = etree.SubElement(contrib, 'name') + contrib = xml_elem('contributor', feed) + name = xml_elem('name', contrib) name.text = c.get('name') if c.get('email'): - email = etree.SubElement(contrib, 'email') + email = xml_elem('email', contrib) email.text = c.get('email') if c.get('uri'): - uri = etree.SubElement(contrib, 'uri') + uri = xml_elem('uri', contrib) uri.text = c.get('uri') if self.__atom_generator and self.__atom_generator.get('value'): - generator = etree.SubElement(feed, 'generator') + generator = xml_elem('generator', feed) generator.text = self.__atom_generator['value'] if self.__atom_generator.get('uri'): generator.attrib['uri'] = self.__atom_generator['uri'] @@ -174,19 +174,19 @@ def _create_atom(self, extensions=True): generator.attrib['version'] = self.__atom_generator['version'] if self.__atom_icon: - icon = etree.SubElement(feed, 'icon') + icon = xml_elem('icon', feed) icon.text = self.__atom_icon if self.__atom_logo: - logo = etree.SubElement(feed, 'logo') + logo = xml_elem('logo', feed) logo.text = self.__atom_logo if self.__atom_rights: - rights = etree.SubElement(feed, 'rights') + rights = xml_elem('rights', feed) rights.text = self.__atom_rights if self.__atom_subtitle: - subtitle = etree.SubElement(feed, 'subtitle') + subtitle = xml_elem('subtitle', feed) subtitle.text = self.__atom_subtitle if extensions: @@ -255,8 +255,8 @@ def _create_rss(self, extensions=True): 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') + feed = xml_elem('rss', version='2.0', nsmap=nsmap) + channel = xml_elem('channel', feed) if not (self.__rss_title and self.__rss_link and self.__rss_description): @@ -265,18 +265,17 @@ def _create_rss(self, extensions=True): ([] if self.__rss_description else ['description']) missing = ', '.join(missing) raise ValueError('Required fields not set (%s)' % missing) - title = etree.SubElement(channel, 'title') + title = xml_elem('title', channel) title.text = self.__rss_title - link = etree.SubElement(channel, 'link') + link = xml_elem('link', channel) link.text = self.__rss_link - desc = etree.SubElement(channel, 'description') + desc = xml_elem('description', channel) 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') + selflink = xml_elem('{http://www.w3.org/2005/Atom}link', + channel, href=ln['href'], rel='self') if ln.get('type'): selflink.attrib['type'] = ln['type'] if ln.get('hreflang'): @@ -288,12 +287,12 @@ def _create_rss(self, extensions=True): break if self.__rss_category: for cat in self.__rss_category: - category = etree.SubElement(channel, 'category') + category = xml_elem('category', channel) category.text = cat['value'] if cat.get('domain'): category.attrib['domain'] = cat['domain'] if self.__rss_cloud: - cloud = etree.SubElement(channel, 'cloud') + cloud = xml_elem('cloud', channel) cloud.attrib['domain'] = self.__rss_cloud.get('domain') cloud.attrib['port'] = self.__rss_cloud.get('port') cloud.attrib['path'] = self.__rss_cloud.get('path') @@ -301,69 +300,69 @@ def _create_rss(self, extensions=True): 'registerProcedure') cloud.attrib['protocol'] = self.__rss_cloud.get('protocol') if self.__rss_copyright: - copyright = etree.SubElement(channel, 'copyright') + copyright = xml_elem('copyright', channel) copyright.text = self.__rss_copyright if self.__rss_docs: - docs = etree.SubElement(channel, 'docs') + docs = xml_elem('docs', channel) docs.text = self.__rss_docs if self.__rss_generator: - generator = etree.SubElement(channel, 'generator') + generator = xml_elem('generator', channel) generator.text = self.__rss_generator if self.__rss_image: - image = etree.SubElement(channel, 'image') - url = etree.SubElement(image, 'url') + image = xml_elem('image', channel) + url = xml_elem('url', image) url.text = self.__rss_image.get('url') - title = etree.SubElement(image, 'title') + title = xml_elem('title', image) title.text = self.__rss_image.get('title', self.__rss_title) - link = etree.SubElement(image, 'link') + link = xml_elem('link', image) link.text = self.__rss_image.get('link', self.__rss_link) if self.__rss_image.get('width'): - width = etree.SubElement(image, 'width') + width = xml_elem('width', image) width.text = self.__rss_image.get('width') if self.__rss_image.get('height'): - height = etree.SubElement(image, 'height') + height = xml_elem('height', image) height.text = self.__rss_image.get('height') if self.__rss_image.get('description'): - description = etree.SubElement(image, 'description') + description = xml_elem('description', image) description.text = self.__rss_image.get('description') if self.__rss_language: - language = etree.SubElement(channel, 'language') + language = xml_elem('language', channel) language.text = self.__rss_language if self.__rss_lastBuildDate: - lastBuildDate = etree.SubElement(channel, 'lastBuildDate') + lastBuildDate = xml_elem('lastBuildDate', channel) lastBuildDate.text = formatRFC2822(self.__rss_lastBuildDate) if self.__rss_managingEditor: - managingEditor = etree.SubElement(channel, 'managingEditor') + managingEditor = xml_elem('managingEditor', channel) managingEditor.text = self.__rss_managingEditor if self.__rss_pubDate: - pubDate = etree.SubElement(channel, 'pubDate') + pubDate = xml_elem('pubDate', channel) pubDate.text = formatRFC2822(self.__rss_pubDate) if self.__rss_rating: - rating = etree.SubElement(channel, 'rating') + rating = xml_elem('rating', channel) rating.text = self.__rss_rating if self.__rss_skipHours: - skipHours = etree.SubElement(channel, 'skipHours') + skipHours = xml_elem('skipHours', channel) for h in self.__rss_skipHours: - hour = etree.SubElement(skipHours, 'hour') + hour = xml_elem('hour', skipHours) hour.text = str(h) if self.__rss_skipDays: - skipDays = etree.SubElement(channel, 'skipDays') + skipDays = xml_elem('skipDays', channel) for d in self.__rss_skipDays: - day = etree.SubElement(skipDays, 'day') + day = xml_elem('day', skipDays) day.text = d if self.__rss_textInput: - textInput = etree.SubElement(channel, 'textInput') + textInput = xml_elem('textInput', channel) 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 = xml_elem('ttl', channel) ttl.text = str(self.__rss_ttl) if self.__rss_webMaster: - webMaster = etree.SubElement(channel, 'webMaster') + webMaster = xml_elem('webMaster', channel) webMaster.text = self.__rss_webMaster if extensions: diff --git a/feedgen/util.py b/feedgen/util.py index ca4ad58..8b4e6e5 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -10,6 +10,28 @@ ''' import locale import sys +import lxml # nosec - we configure a safe parser below + +# Configure a safe parser which does not allow XML entity expansion +parser = lxml.etree.XMLParser( + attribute_defaults=False, + dtd_validation=False, + load_dtd=False, + no_network=True, + recover=False, + remove_pis=True, + resolve_entities=False, + huge_tree=False) + + +def xml_fromstring(xmlstring): + return lxml.etree.fromstring(xmlstring, parser) # nosec - safe parser + + +def xml_elem(name, parent=None, **kwargs): + if parent is not None: + return lxml.etree.SubElement(parent, name, **kwargs) + return lxml.etree.Element(name, **kwargs) def ensure_format(val, allowed, required, allowed_values=None, defaults=None): From 2b77fd58eb1fb230e3c80e2cc4c5b0c4d05b6150 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Tue, 28 Jan 2020 17:50:53 +0100 Subject: [PATCH 76/96] Introduce Bandit Security Linter This patch introduces the bandit security linter as part of the CI tests run on feedgen. --- .travis.yml | 2 +- Makefile | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 80721da..c58695e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ python: - 3.8 install: - - pip install flake8 python-coveralls coverage liccheck + - pip install bandit flake8 python-coveralls coverage liccheck - pip install -r requirements.txt - python setup.py bdist_wheel - pip install dist/feedgen* diff --git a/Makefile b/Makefile index 50c88d9..a4c34de 100644 --- a/Makefile +++ b/Makefile @@ -51,3 +51,4 @@ publish: test: coverage run --source=feedgen -m unittest discover -s tests flake8 $$(find setup.py tests feedgen -name '*.py') + bandit -r feedgen From ffe3e4d752ac76e23c879c35682c310c2b1ccb86 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Tue, 28 Jan 2020 17:58:13 +0100 Subject: [PATCH 77/96] Release 0.9.0 - Prevent XML Denial of Service Attacks - Make Generator Optional (Atom) - Properly Parse text/xml - Add support for html summaries for Atom feeds --- feedgen/version.py | 2 +- python-feedgen.spec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 436c851..2a59ec0 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 8, 0) +version = (0, 9, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index eac1fcb..b2e6515 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %global pypi_name feedgen Name: python-%{pypi_name} -Version: 0.8.0 +Version: 0.9.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) From 50d2b275d7051f603c15404e37be14f3646bf605 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Mon, 9 Mar 2020 10:14:24 +0100 Subject: [PATCH 78/96] Add itunes:season and itunes:episode tags --- feedgen/__main__.py | 2 ++ feedgen/ext/podcast_entry.py | 30 +++++++++++++++++++++++++++ tests/test_extensions/test_podcast.py | 4 ++++ 3 files changed, 36 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index abc0737..d64cdce 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -101,6 +101,8 @@ def main(): 'adipiscing elit. Verba tu fingas et ea ' + 'dicas, quae non sentias?') fe.podcast.itunes_author('Lars Kiesow') + fe.podcast.itunes_season(1) + fe.podcast.itunes_episode(1) print_enc(fg.rss_str(pretty=True)) elif arg == 'torrent': diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 2a3771f..cd746e3 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -30,6 +30,8 @@ def __init__(self): self.__itunes_order = None self.__itunes_subtitle = None self.__itunes_summary = None + self.__itunes_season = None + self.__itunes_episode = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -77,6 +79,14 @@ def extend_rss(self, entry): if self.__itunes_summary: summary = xml_elem('{%s}summary' % ITUNES_NS, entry) summary.text = self.__itunes_summary + + if self.__itunes_season: + season = xml_elem('{%s}season' % ITUNES_NS, entry) + season.text = str(self.__itunes_season) + + if self.__itunes_episode: + episode = xml_elem('{%s}episode' % ITUNES_NS, entry) + episode.text = str(self.__itunes_episode) return entry def itunes_author(self, itunes_author=None): @@ -242,3 +252,23 @@ def itunes_summary(self, itunes_summary=None): if itunes_summary is not None: self.__itunes_summary = itunes_summary return self.__itunes_summary + + def itunes_season(self, itunes_season=None): + '''Get or set the itunes:season value for the podcast episode. + + :param itunes_season: Season number of the podcast epiosode. + :returns: Season number of the podcast episode. + ''' + if itunes_season is not None: + self.__itunes_season = int(itunes_season) + return self.__itunes_season + + def itunes_episode(self, itunes_episode=None): + '''Get or set the itunes:episode value for the podcast episode. + + :param itunes_season: Episode number of the podcast epiosode. + :returns: Episode number of the podcast episode. + ''' + if itunes_episode is not None: + self.__itunes_episode = int(itunes_episode) + return self.__itunes_episode diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index a41c96c..6b985fa 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -78,6 +78,8 @@ def test_podcastEntryItems(self): fe.podcast.itunes_order(1) fe.podcast.itunes_subtitle('x') fe.podcast.itunes_summary('x') + fe.podcast.itunes_season(1) + fe.podcast.itunes_episode(1) assert fe.podcast.itunes_author() == 'Lars Kiesow' assert fe.podcast.itunes_block() == 'x' assert fe.podcast.itunes_duration() == '00:01:30' @@ -87,6 +89,8 @@ def test_podcastEntryItems(self): assert fe.podcast.itunes_order() == 1 assert fe.podcast.itunes_subtitle() == 'x' assert fe.podcast.itunes_summary() == 'x' + assert fe.podcast.itunes_season() == 1 + assert fe.podcast.itunes_episode() == 1 # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From 7ecc1e089983de0ed3e2116e9c098067009abba2 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 11:27:11 +0200 Subject: [PATCH 79/96] Add itunes tag type --- feedgen/__main__.py | 1 + feedgen/ext/podcast.py | 33 +++++++++++++++++++++++++++ tests/test_extensions/test_podcast.py | 2 ++ 3 files changed, 36 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index d64cdce..855c8a7 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -100,6 +100,7 @@ def main(): fg.podcast.itunes_summary('Lorem ipsum dolor sit amet, consectetur ' + 'adipiscing elit. Verba tu fingas et ea ' + 'dicas, quae non sentias?') + fg.podcast.itunes_type('episodic') fe.podcast.itunes_author('Lars Kiesow') fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 4c7eb0b..d070944 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -32,6 +32,7 @@ def __init__(self): self.__itunes_owner = None self.__itunes_subtitle = None self.__itunes_summary = None + self.__itunes_type = None def extend_ns(self): return {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} @@ -96,6 +97,10 @@ def extend_rss(self, rss_feed): summary = xml_elem('{%s}summary' % ITUNES_NS, channel) summary.text = self.__itunes_summary + if self.__itunes_type: + type = xml_elem('{%s}type' % ITUNES_NS, channel) + type.text = self.__itunes_type + return rss_feed def itunes_author(self, itunes_author=None): @@ -318,6 +323,34 @@ def itunes_summary(self, itunes_summary=None): self.__itunes_summary = itunes_summary return self.__itunes_summary + def itunes_type(self, itunes_type=None): + '''Get or set the itunes:type value of the podcast. This tag should + be used to indicate the type of your podcast. + The two values for this tag are "episodic" and "serial". + + If your show is Serial you must use this tag. + + Specify episodic when episodes are intended to be consumed without any + specific order. Apple Podcasts will present newest episodes first and + display the publish date (required) of each episode. If organized into + seasons, the newest season will be presented first - otherwise, + episodes will be grouped by year published, newest first. + + Specify serial when episodes are intended to be consumed in sequential + order. Apple Podcasts will present the oldest episodes first and + display the episode numbers (required) of each episode. If organized + into seasons, the newest season will be presented first and + numbers must be given for each episode. + + :param itunes_type: The type of the podcast + :returns: type of the pdocast. + ''' + if itunes_type is not None: + if itunes_type not in ('episodic', 'serial'): + raise ValueError('Invalid value for type tag') + self.__itunes_type = itunes_type + return self.__itunes_type + _itunes_categories = { 'Arts': [ 'Design', 'Fashion & Beauty', 'Food', 'Literature', diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index 6b985fa..f81f773 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -52,6 +52,7 @@ def test_podcastItems(self): fg.podcast.itunes_image('x.png') fg.podcast.itunes_subtitle('x') fg.podcast.itunes_summary('x') + fg.podcast.itunes_type('episodic') assert fg.podcast.itunes_author() == 'Lars Kiesow' assert fg.podcast.itunes_block() == 'x' assert fg.podcast.itunes_complete() == 'no' @@ -59,6 +60,7 @@ def test_podcastItems(self): assert fg.podcast.itunes_image() == 'x.png' assert fg.podcast.itunes_subtitle() == 'x' assert fg.podcast.itunes_summary() == 'x' + assert fg.podcast.itunes_type() == 'episodic' # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From 7582e7b1d6d73e32082248e7cd468252c7d05ff7 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 11:48:49 +0200 Subject: [PATCH 80/96] Add tag for itunes_title --- feedgen/__main__.py | 1 + feedgen/ext/podcast_entry.py | 19 +++++++++++++++++++ tests/test_extensions/test_podcast.py | 2 ++ 3 files changed, 22 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 855c8a7..77da67b 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -104,6 +104,7 @@ def main(): fe.podcast.itunes_author('Lars Kiesow') fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) + fe.podcast.itunes_title('First podcast episode') print_enc(fg.rss_str(pretty=True)) elif arg == 'torrent': diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index cd746e3..2d201b5 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -32,6 +32,7 @@ def __init__(self): self.__itunes_summary = None self.__itunes_season = None self.__itunes_episode = None + self.__itunes_title = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -87,6 +88,10 @@ def extend_rss(self, entry): if self.__itunes_episode: episode = xml_elem('{%s}episode' % ITUNES_NS, entry) episode.text = str(self.__itunes_episode) + + if self.__itunes_title: + title = xml_elem('{%s}title' % ITUNES_NS, entry) + title.text = self.__itunes_title return entry def itunes_author(self, itunes_author=None): @@ -272,3 +277,17 @@ def itunes_episode(self, itunes_episode=None): if itunes_episode is not None: self.__itunes_episode = int(itunes_episode) return self.__itunes_episode + + def itunes_title(self, itunes_title=None): + '''Get or set the itunes:title value for the podcast episode. + + An episode title specific for Apple Podcasts. Don’t specify the episode + number or season number in this tag. Also, don’t repeat the title of + your show within your episode title. + + :param itunes_title: Episode title specific for Apple Podcasts + :returns: Title specific for Apple Podcast + ''' + if itunes_title is not None: + self.__itunes_title = itunes_title + return self.__itunes_title diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index f81f773..4b7770b 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -82,6 +82,7 @@ def test_podcastEntryItems(self): fe.podcast.itunes_summary('x') fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) + fe.podcast.itunes_title('Podcast Title') assert fe.podcast.itunes_author() == 'Lars Kiesow' assert fe.podcast.itunes_block() == 'x' assert fe.podcast.itunes_duration() == '00:01:30' @@ -93,6 +94,7 @@ def test_podcastEntryItems(self): assert fe.podcast.itunes_summary() == 'x' assert fe.podcast.itunes_season() == 1 assert fe.podcast.itunes_episode() == 1 + assert fe.podcast.itunes_title() == 'Podcast Title' # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From 493bfd93b67255ce70829cdea39d999dac5e49f5 Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 11:57:18 +0200 Subject: [PATCH 81/96] Additional check for itunes_type --- feedgen/ext/podcast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index d070944..c455a10 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -97,7 +97,7 @@ def extend_rss(self, rss_feed): summary = xml_elem('{%s}summary' % ITUNES_NS, channel) summary.text = self.__itunes_summary - if self.__itunes_type: + if self.__itunes_type in ('episodic', 'serial'): type = xml_elem('{%s}type' % ITUNES_NS, channel) type.text = self.__itunes_type From 2777ee939c6f224d7a10bddff2d3354275f2acdd Mon Sep 17 00:00:00 2001 From: Oli Kamer Date: Tue, 21 Apr 2020 12:19:01 +0200 Subject: [PATCH 82/96] Add episodeType tag --- feedgen/__main__.py | 1 + feedgen/ext/podcast_entry.py | 28 +++++++++++++++++++++++++++ tests/test_extensions/test_podcast.py | 2 ++ 3 files changed, 31 insertions(+) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 77da67b..8388dbc 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -105,6 +105,7 @@ def main(): fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) fe.podcast.itunes_title('First podcast episode') + fe.podcast.itunes_episode_type('full') print_enc(fg.rss_str(pretty=True)) elif arg == 'torrent': diff --git a/feedgen/ext/podcast_entry.py b/feedgen/ext/podcast_entry.py index 2d201b5..2d60c2d 100644 --- a/feedgen/ext/podcast_entry.py +++ b/feedgen/ext/podcast_entry.py @@ -33,6 +33,7 @@ def __init__(self): self.__itunes_season = None self.__itunes_episode = None self.__itunes_title = None + self.__itunes_episode_type = None def extend_rss(self, entry): '''Add additional fields to an RSS item. @@ -92,6 +93,10 @@ def extend_rss(self, entry): if self.__itunes_title: title = xml_elem('{%s}title' % ITUNES_NS, entry) title.text = self.__itunes_title + + if self.__itunes_episode_type in ('full', 'trailer', 'bonus'): + episode_type = xml_elem('{%s}episodeType' % ITUNES_NS, entry) + episode_type.text = self.__itunes_episode_type return entry def itunes_author(self, itunes_author=None): @@ -291,3 +296,26 @@ def itunes_title(self, itunes_title=None): if itunes_title is not None: self.__itunes_title = itunes_title return self.__itunes_title + + def itunes_episode_type(self, itunes_episode_type=None): + '''Get or set the itunes:episodeType value of the item. This tag should + be used to indicate the episode type. + The three values for this tag are "full", "trailer" and "bonus". + + If an episode is a trailer or bonus content, use this tag. + + Specify full when you are submitting the complete content of your show. + Specify trailer when you are submitting a short, promotional piece of + content that represents a preview of your current show. + Specify bonus when you are submitting extra content for your show (for + example, behind the scenes information or interviews with the cast) or + cross-promotional content for another show. + + :param itunes_episode_type: The episode type + :returns: type of the episode. + ''' + if itunes_episode_type is not None: + if itunes_episode_type not in ('full', 'trailer', 'bonus'): + raise ValueError('Invalid value for episodeType tag') + self.__itunes_episode_type = itunes_episode_type + return self.__itunes_episode_type diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index 4b7770b..cb7085e 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -83,6 +83,7 @@ def test_podcastEntryItems(self): fe.podcast.itunes_season(1) fe.podcast.itunes_episode(1) fe.podcast.itunes_title('Podcast Title') + fe.podcast.itunes_episode_type('full') assert fe.podcast.itunes_author() == 'Lars Kiesow' assert fe.podcast.itunes_block() == 'x' assert fe.podcast.itunes_duration() == '00:01:30' @@ -95,6 +96,7 @@ def test_podcastEntryItems(self): assert fe.podcast.itunes_season() == 1 assert fe.podcast.itunes_episode() == 1 assert fe.podcast.itunes_title() == 'Podcast Title' + assert fe.podcast.itunes_episode_type() == 'full' # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} From af74c3bbf11d8e23f3a284c02f2b718ec7e02519 Mon Sep 17 00:00:00 2001 From: Chris Ring Date: Sat, 19 Sep 2020 13:52:29 -0700 Subject: [PATCH 83/96] Fix a few comment typos Fixes a few typos in comments and docs. Signed-off-by: Chris Ring --- feedgen/feed.py | 4 ++-- feedgen/util.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..ff28b61 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -761,7 +761,7 @@ def image(self, url=None, title=None, link=None, width=None, height=None, :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. + use the feeds first alternate 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. @@ -802,7 +802,7 @@ def copyright(self, copyright=None): return self.rights(copyright) def subtitle(self, subtitle=None): - '''Get or set the subtitle value of the cannel which contains a + '''Get or set the subtitle value of the channel which contains a human-readable description or subtitle for the feed. This ATOM property will also set the value for rss:description. diff --git a/feedgen/util.py b/feedgen/util.py index 8b4e6e5..5a97887 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -53,7 +53,7 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): allowed_values = {} if defaults is None: defaults = {} - # Make shure that we have a list of dicts. Even if there is only one. + # Make sure that we have a list of dicts. Even if there is only one. if not isinstance(val, list): val = [val] for elem in val: From 44531536cff8c0e8722c82895c670792333ac08c Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Tue, 16 Nov 2021 06:31:31 +1100 Subject: [PATCH 84/96] docs: Fix a few typos There are small typos in: - feedgen/ext/dc.py - feedgen/ext/geo_entry.py - feedgen/feed.py - feedgen/util.py Fixes: - Should read `default` rather than `deault`. - Should read `already` rather than `alredy`. - Should read `omitted` rather than `omittet`. - Should read `sure` rather than `shure`. - Should read `rights` rather than `rightss`. - Should read `generated` rather than `generaated`. --- feedgen/ext/dc.py | 28 ++++++++++++++-------------- feedgen/ext/geo_entry.py | 2 +- feedgen/feed.py | 4 ++-- feedgen/util.py | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index f731c0b..466c66d 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -91,7 +91,7 @@ def dc_contributor(self, contributor=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-contributor :param contributor: Contributor or list of contributors. - :param replace: Replace alredy set contributors (deault: False). + :param replace: Replace already set contributors (default: False). :returns: List of contributors. ''' if contributor is not None: @@ -139,7 +139,7 @@ def dc_creator(self, creator=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-creator :param creator: Creator or list of creators. - :param replace: Replace alredy set creators (deault: False). + :param replace: Replace already set creators (default: False). :returns: List of creators. ''' if creator is not None: @@ -158,7 +158,7 @@ def dc_date(self, date=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-date :param date: Date or list of dates. - :param replace: Replace alredy set dates (deault: True). + :param replace: Replace already set dates (default: True). :returns: List of dates. ''' if date is not None: @@ -176,7 +176,7 @@ def dc_description(self, description=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-description :param description: Description or list of descriptions. - :param replace: Replace alredy set descriptions (deault: True). + :param replace: Replace already set descriptions (default: True). :returns: List of descriptions. ''' if description is not None: @@ -195,7 +195,7 @@ def dc_format(self, format=None, replace=True): 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). + :param replace: Replace already set format (default: True). :returns: Format of the resource. ''' if format is not None: @@ -214,7 +214,7 @@ def dc_identifier(self, identifier=None, replace=True): 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). + :param replace: Replace already set identifier (default: True). :returns: Identifiers of the resource. ''' if identifier is not None: @@ -233,7 +233,7 @@ def dc_language(self, language=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-language :param language: Language or list of languages. - :param replace: Replace alredy set languages (deault: True). + :param replace: Replace already set languages (default: True). :returns: List of languages. ''' if language is not None: @@ -252,7 +252,7 @@ def dc_publisher(self, publisher=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-publisher :param publisher: Publisher or list of publishers. - :param replace: Replace alredy set publishers (deault: False). + :param replace: Replace already set publishers (default: False). :returns: List of publishers. ''' if publisher is not None: @@ -270,7 +270,7 @@ def dc_relation(self, relation=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-relation :param relation: Relation or list of relations. - :param replace: Replace alredy set relations (deault: False). + :param replace: Replace already set relations (default: False). :returns: List of relations. ''' if relation is not None: @@ -289,7 +289,7 @@ def dc_rights(self, rights=None, replace=False): 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). + :param replace: Replace already set rights (default: False). :returns: List of rights information. ''' if rights is not None: @@ -313,7 +313,7 @@ def dc_source(self, source=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-source :param source: Source or list of sources. - :param replace: Replace alredy set sources (deault: False). + :param replace: Replace already set sources (default: False). :returns: List of sources. ''' if source is not None: @@ -331,7 +331,7 @@ def dc_subject(self, subject=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-subject :param subject: Subject or list of subjects. - :param replace: Replace alredy set subjects (deault: False). + :param replace: Replace already set subjects (default: False). :returns: List of subjects. ''' if subject is not None: @@ -349,7 +349,7 @@ def dc_title(self, title=None, replace=True): http://dublincore.org/documents/dcmi-terms/#elements-title :param title: Title or list of titles. - :param replace: Replace alredy set titles (deault: False). + :param replace: Replace already set titles (default: False). :returns: List of titles. ''' if title is not None: @@ -368,7 +368,7 @@ def dc_type(self, type=None, replace=False): http://dublincore.org/documents/dcmi-terms/#elements-type :param type: Type or list of types. - :param replace: Replace alredy set types (deault: False). + :param replace: Replace already set types (default: False). :returns: List of types. ''' if type is not None: diff --git a/feedgen/ext/geo_entry.py b/feedgen/ext/geo_entry.py index bb06cc2..4c18cfe 100644 --- a/feedgen/ext/geo_entry.py +++ b/feedgen/ext/geo_entry.py @@ -281,7 +281,7 @@ def geom_from_geo_interface(self, geom): - Point - LineString - Polygon (if there are holes / donuts in the polygons a warning will - be generaated + be generated Other GeoJson types will raise a ``ValueError``. diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..630be02 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -997,7 +997,7 @@ def webMaster(self, webMaster=None): def add_entry(self, feedEntry=None, order='prepend'): '''This method will add a new entry to the feed. If the feedEntry - argument is omittet a new Entry object is created automatically. This + argument is omitted a new Entry object is created automatically. This is the preferred way to add new entries to a feed. :param feedEntry: FeedEntry object to add. @@ -1042,7 +1042,7 @@ def add_entry(self, feedEntry=None, order='prepend'): 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 + omitted a new FeedEntry object is created automatically. This is just another name for add_entry(...) ''' return self.add_entry(item) diff --git a/feedgen/util.py b/feedgen/util.py index 8b4e6e5..5a97887 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -53,7 +53,7 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): allowed_values = {} if defaults is None: defaults = {} - # Make shure that we have a list of dicts. Even if there is only one. + # Make sure that we have a list of dicts. Even if there is only one. if not isinstance(val, list): val = [val] for elem in val: From 1e9282d0c922f6aab649554d2939dcf68341503b Mon Sep 17 00:00:00 2001 From: Chenxing Luo Date: Mon, 2 Oct 2023 11:09:09 -0400 Subject: [PATCH 85/96] Fix etree to string conversion in FeedGenerator When converting `feed` object to a string, using `atom_str` or `rss_str` methods, with `etree.tostring` method, `feed` instead of `doc` is supplied. In this case, changes applied outside the was lost (e.g., adding a processing instruction `` as sibling to ). The `feed` object has now been replaced by the `doc` object in both the `_create_atom` and `_create_rss` methods, ensuring the correct conversion and preventing possible lost of information in the feed generation. --- feedgen/feed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..fa82702 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -220,7 +220,7 @@ def atom_str(self, pretty=False, extensions=True, encoding='UTF-8', `_ ''' feed, doc = self._create_atom(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, + return etree.tostring(doc, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) def atom_file(self, filename, extensions=True, pretty=False, @@ -396,7 +396,7 @@ def rss_str(self, pretty=False, extensions=True, encoding='UTF-8', `_ ''' feed, doc = self._create_rss(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding, + return etree.tostring(doc, pretty_print=pretty, encoding=encoding, xml_declaration=xml_declaration) def rss_file(self, filename, extensions=True, pretty=False, From 966fea46ba4954f5511266e630697c4f06fb1221 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 21:29:04 +0100 Subject: [PATCH 86/96] Fix flake8 complaints This pull request fixes a number of complaints the current version of flake8 has about the code. --- feedgen/__main__.py | 10 ++++----- feedgen/entry.py | 50 +++++++++++++++++++++--------------------- feedgen/ext/base.py | 3 ++- feedgen/ext/podcast.py | 4 ++-- feedgen/feed.py | 24 ++++++++++---------- feedgen/util.py | 6 ++--- tests/test_main.py | 4 ++-- 7 files changed, 51 insertions(+), 50 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index abc0737..9f008b3 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -36,8 +36,8 @@ def print_enc(s): - '''Print function compatible with both python2 and python3 accepting strings - and byte arrays. + '''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) @@ -73,9 +73,9 @@ def main(): 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 + 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.''') diff --git a/feedgen/entry.py b/feedgen/entry.py index 66400ba..71d295a 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -58,8 +58,8 @@ def _add_text_elm(entry, data, name): class FeedEntry(object): - '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds item - node. + '''FeedEntry call representing an ATOM feeds entry node or an RSS feeds + item node. ''' def __init__(self): @@ -115,8 +115,8 @@ def atom_entry(self, extensions=True): # 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 ' + + if not [link for link in links if link.get('rel') == 'alternate']: + raise ValueError('Entry must contain an alternate link or ' 'a content element.') # Add author elements @@ -136,18 +136,18 @@ def atom_entry(self, extensions=True): _add_text_elm(entry, self.__atom_content, 'content') - for l in self.__atom_link or []: - link = xml_elem('link', entry, 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 link in self.__atom_link or []: + link = xml_elem('link', entry, href=link['href']) + if link.get('rel'): + link.attrib['rel'] = link['rel'] + if link.get('type'): + link.attrib['type'] = link['type'] + if link.get('hreflang'): + link.attrib['hreflang'] = link['hreflang'] + if link.get('title'): + link.attrib['title'] = link['title'] + if link.get('length'): + link.attrib['length'] = link['length'] _add_text_elm(entry, self.__atom_summary, 'summary') @@ -449,13 +449,13 @@ def link(self, link=None, replace=False, **kwargs): {'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' + for link in self.__atom_link: + if link.get('rel') == 'alternate': + self.__rss_link = link['href'] + elif link.get('rel') == 'enclosure': + self.__rss_enclosure = {'url': link['href']} + self.__rss_enclosure['type'] = link.get('type') + self.__rss_enclosure['length'] = link.get('length') or '0' # return the set with more information (atom) return self.__atom_link @@ -574,8 +574,8 @@ def contributor(self, contributor=None, replace=False, **kwargs): 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. + '''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 diff --git a/feedgen/ext/base.py b/feedgen/ext/base.py index 521139e..b94826d 100644 --- a/feedgen/ext/base.py +++ b/feedgen/ext/base.py @@ -21,7 +21,8 @@ def extend_ns(self): return dict() def extend_rss(self, feed): - '''Extend a RSS feed xml structure containing all previously set fields. + '''Extend a RSS feed xml structure containing all previously set + fields. :param feed: The feed xml root element. :returns: The feed root element. diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 4c7eb0b..e0d7d05 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -292,8 +292,8 @@ def itunes_owner(self, name=None, email=None): 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 + '''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. diff --git a/feedgen/feed.py b/feedgen/feed.py index 9ebd219..33e0c3f 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -130,18 +130,18 @@ def _create_atom(self, extensions=True): uri = xml_elem('uri', author) uri.text = a.get('uri') - for l in self.__atom_link or []: - link = xml_elem('link', feed, 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 ln in self.__atom_link or []: + link = xml_elem('link', feed, href=ln['href']) + if ln.get('rel'): + link.attrib['rel'] = ln['rel'] + if ln.get('type'): + link.attrib['type'] = ln['type'] + if ln.get('hreflang'): + link.attrib['hreflang'] = ln['hreflang'] + if ln.get('title'): + link.attrib['title'] = ln['title'] + if ln.get('length'): + link.attrib['length'] = ln['length'] for c in self.__atom_category or []: cat = xml_elem('category', feed, term=c['term']) diff --git a/feedgen/util.py b/feedgen/util.py index 8b4e6e5..e8125e8 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -35,9 +35,9 @@ def xml_elem(name, parent=None, **kwargs): 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. diff --git a/tests/test_main.py b/tests/test_main.py index efea777..af0b19e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,8 +22,8 @@ def test_usage(self): assert e.code is None def test_feed(self): - for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', 'dc.atom',\ - 'syndication.rss', 'syndication.atom': + for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', \ + 'dc.atom', 'syndication.rss', 'syndication.atom': sys.argv = ['feedgen', ftype] try: __main__.main() From 8eef40385da97453b5a3bc6750158b79b5446f3c Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 21:38:17 +0100 Subject: [PATCH 87/96] Update dependency versions This patch updates the dependency version set in `requirements.txt` to allow using newer dependency versions. --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3c01ee4..631c4c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -lxml==4.2.5 -python_dateutil==2.8.0 +lxml>=4.2.5 +python_dateutil>=2.8.0 From c1c2859a7c8bfde66652c18ca5ec7e701436883e Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 20:46:57 +0100 Subject: [PATCH 88/96] Switch to GitHub Actions This patch switches from Travis CI to GitHub Actions for running CI tests on pull requests and pushes. --- .github/workflows/tests.yml | 46 +++++++++++++++++++++++++++++++++++++ .travis.yml | 25 -------------------- readme.rst | 9 -------- 3 files changed, 46 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c573c55 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + - push + - pull_request + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: set up python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: install system dependencies + run: | + sudo apt update + sudo apt install python3-lxml python3-dateutil + + - name: install Python dependencies + run: | + pip install bandit flake8 coverage liccheck + + - name: install feedgen + run: | + python setup.py install + + - name: run linter + run: make test + + - name: run license check + run: liccheck -s .licenses.ini + + - name: run tests + run: | + python -m feedgen + python -m feedgen atom + python -m feedgen rss + + + - name: run coverage + run: coverage report --fail-under=93 + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c58695e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: python - -dist: bionic - -# https://devguide.python.org/#branchstatus -python: - - 3.6 - - 3.7 - - 3.8 - -install: - - pip install bandit flake8 python-coveralls coverage liccheck - - pip install -r requirements.txt - - python setup.py bdist_wheel - - pip install dist/feedgen* - -script: - - make test - - liccheck -s .licenses.ini - - python -m feedgen - - python -m feedgen atom - - python -m feedgen rss - -after_success: - - coveralls diff --git a/readme.rst b/readme.rst index 3aa2c8f..3edeaff 100644 --- a/readme.rst +++ b/readme.rst @@ -2,15 +2,6 @@ Feedgenerator ============= -.. image:: https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master - :target: https://travis-ci.org/lkiesow/python-feedgen - :alt: Build Status - -.. image:: https://coveralls.io/repos/github/lkiesow/python-feedgen/badge.svg?branch=master - :target: https://coveralls.io/github/lkiesow/python-feedgen?branch=master - :alt: Test Coverage Status - - 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. From ef236ae4909bfd8f8254b36b2a68e2455623345f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 21 Dec 2023 23:10:50 +0100 Subject: [PATCH 89/96] Improve module documentation This patch updates the modules description in `__init__.py` which was a bit weird. This closes #102 --- feedgen/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/feedgen/__init__.py b/feedgen/__init__.py index 624658e..b28c0e9 100644 --- a/feedgen/__init__.py +++ b/feedgen/__init__.py @@ -62,17 +62,15 @@ 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:: + convenient way to do this, is to call the method add_entry(...) like this:: >>> 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 FeedGenerator's method add_entry(...) will automatically create a new + FeedEntry object, append it to the feeds internal list of entries and + return it, so that additional data can be added. ---------- Extensions From 2bc43d3b2616324fe5e225b58c49fa925e2fecc1 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Tue, 28 Jan 2020 23:07:13 +0100 Subject: [PATCH 90/96] Use Unittest Asserts This patch switches to the assert statements provided by Python's unit test framework to assure the statements are always executed and produce proper error messages in case of test failures. --- tests/test_entry.py | 75 ++++---- tests/test_extensions/test_dc.py | 6 +- tests/test_extensions/test_geo.py | 5 +- tests/test_extensions/test_media.py | 16 +- tests/test_extensions/test_podcast.py | 54 +++--- tests/test_extensions/test_syndication.py | 6 +- tests/test_extensions/test_torrent.py | 14 +- tests/test_feed.py | 225 ++++++++++++++-------- tests/test_main.py | 17 +- 9 files changed, 238 insertions(+), 180 deletions(-) diff --git a/tests/test_entry.py b/tests/test_entry.py index 5eaeffa..adfd8b5 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -44,8 +44,8 @@ def setUp(self): def test_setEntries(self): fg2 = FeedGenerator() fg2.entry(self.fg.entry()) - assert len(fg2.entry()) == 3 - assert self.fg.entry() == fg2.entry() + self.assertEqual(len(fg2.entry()), 3) + self.assertEqual(self.fg.entry(), fg2.entry()) def test_loadExtension(self): fe = self.fg.add_item() @@ -53,64 +53,64 @@ def test_loadExtension(self): fe.title(u'…') fe.content(u'…') fe.load_extension('base') - assert fe.base - assert self.fg.atom_str() + self.assertTrue(fe.base) + self.assertTrue(self.fg.atom_str()) def test_checkEntryNumbers(self): fg = self.fg - assert len(fg.entry()) == 3 + self.assertEqual(len(fg.entry()), 3) def test_TestEntryItems(self): fe = self.fg.add_item() fe.title('qwe') - assert fe.title() == 'qwe' + self.assertEqual(fe.title(), 'qwe') author = fe.author(email='ldoe@example.com')[0] - assert not author.get('name') - assert author.get('email') == 'ldoe@example.com' + self.assertFalse(author.get('name')) + self.assertEqual(author.get('email'), 'ldoe@example.com') author = fe.author(name='John Doe', email='jdoe@example.com', replace=True)[0] - assert author.get('name') == 'John Doe' - assert author.get('email') == 'jdoe@example.com' + self.assertEqual(author.get('name'), 'John Doe') + self.assertEqual(author.get('email'), 'jdoe@example.com') contributor = fe.contributor(name='John Doe', email='jdoe@ex.com')[0] - assert contributor == fe.contributor()[0] - assert contributor.get('name') == 'John Doe' - assert contributor.get('email') == 'jdoe@ex.com' + self.assertEqual(contributor, fe.contributor()[0]) + self.assertEqual(contributor.get('name'), 'John Doe') + self.assertEqual(contributor.get('email'), 'jdoe@ex.com') link = fe.link(href='http://lkiesow.de', rel='alternate')[0] - assert link == fe.link()[0] - assert link.get('href') == 'http://lkiesow.de' - assert link.get('rel') == 'alternate' + self.assertEqual(link, fe.link()[0]) + self.assertEqual(link.get('href'), 'http://lkiesow.de') + self.assertEqual(link.get('rel'), 'alternate') fe.guid('123') - assert fe.guid().get('guid') == '123' + self.assertEqual(fe.guid().get('guid'), '123') fe.updated('2017-02-05 13:26:58+01:00') - assert fe.updated().year == 2017 + self.assertEqual(fe.updated().year, 2017) fe.summary('asdf') - assert fe.summary() == {'summary': 'asdf'} + self.assertEqual(fe.summary(), {'summary': 'asdf'}) fe.description('asdfx') - assert fe.description() == 'asdfx' + self.assertEqual(fe.description(), 'asdfx') fe.pubDate('2017-02-05 13:26:58+01:00') - assert fe.pubDate().year == 2017 + self.assertEqual(fe.pubDate().year, 2017) fe.rights('asdfx') - assert fe.rights() == 'asdfx' + self.assertEqual(fe.rights(), 'asdfx') source = fe.source(url='https://example.com', title='Test') - assert source.get('title') == 'Test' - assert source.get('url') == 'https://example.com' + self.assertEqual(source.get('title'), 'Test') + self.assertEqual(source.get('url'), 'https://example.com') fe.comments('asdfx') - assert fe.comments() == 'asdfx' + self.assertEqual(fe.comments(), 'asdfx') fe.enclosure(url='http://lkiesow.de', type='text/plain', length='1') - assert fe.enclosure().get('url') == 'http://lkiesow.de' + self.assertEqual(fe.enclosure().get('url'), 'http://lkiesow.de') fe.ttl(8) - assert fe.ttl() == 8 + self.assertEqual(fe.ttl(), 8) self.fg.rss_str() self.fg.atom_str() def test_checkItemNumbers(self): fg = self.fg - assert len(fg.item()) == 3 + self.assertEqual(len(fg.item()), 3) def test_checkEntryContent(self): fg = self.fg - assert fg.entry() + self.assertTrue(fg.entry()) def test_removeEntryByIndex(self): fg = FeedGenerator() @@ -120,9 +120,9 @@ def test_removeEntryByIndex(self): fe = fg.add_entry() fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') - assert len(fg.entry()) == 1 + self.assertEqual(len(fg.entry()), 1) fg.remove_entry(0) - assert len(fg.entry()) == 0 + self.assertEqual(len(fg.entry()), 0) def test_removeEntryByEntry(self): fg = FeedGenerator() @@ -133,9 +133,9 @@ def test_removeEntryByEntry(self): fe.id('http://lernfunk.de/media/654321/1') fe.title('The Third Episode') - assert len(fg.entry()) == 1 + self.assertEqual(len(fg.entry()), 1) fg.remove_entry(fe) - assert len(fg.entry()) == 0 + self.assertEqual(len(fg.entry()), 0) def test_categoryHasDomain(self): fg = FeedGenerator() @@ -147,12 +147,12 @@ def test_categoryHasDomain(self): fe.title('some title') fe.category([ {'term': 'category', - 'scheme': 'http://www.somedomain.com/category', + 'scheme': 'http://somedomain.com/category', 'label': 'Category', }]) result = fg.rss_str() - assert b'domain="http://www.somedomain.com/category"' in result + self.assertIn(b'domain="http://somedomain.com/category"', result) def test_content_cdata_type(self): fg = FeedGenerator() @@ -163,7 +163,8 @@ def test_content_cdata_type(self): fe.title('some title') fe.content('content', type='CDATA') result = fg.atom_str() - assert b'' in result + expected = b'' + self.assertIn(expected, result) def test_summary_html_type(self): fg = FeedGenerator() @@ -176,4 +177,4 @@ def test_summary_html_type(self): fe.summary('

summary

', type='html') result = fg.atom_str() expected = b'<p>summary</p>' - assert expected in result + self.assertIn(expected, result) diff --git a/tests/test_extensions/test_dc.py b/tests/test_extensions/test_dc.py index 1623804..af7dd94 100644 --- a/tests/test_extensions/test_dc.py +++ b/tests/test_extensions/test_dc.py @@ -24,8 +24,8 @@ def test_elements(self): if method.startswith('dc_'): m = getattr(self.fg.dc, method) m(method) - assert m() == [method] + self.assertEqual(m(), [method]) self.fg.id('123') - assert self.fg.atom_str() - assert self.fg.rss_str() + self.assertTrue(self.fg.atom_str()) + self.assertTrue(self.fg.rss_str()) diff --git a/tests/test_extensions/test_geo.py b/tests/test_extensions/test_geo.py index 6dd401b..5de1f80 100644 --- a/tests/test_extensions/test_geo.py +++ b/tests/test_extensions/test_geo.py @@ -403,8 +403,9 @@ def test_geom_from_geointerface_warn_poly_interior(self): # Trigger a warning. fe.geo.geom_from_geo_interface(self.polygon_with_interior) # Verify some things - assert len(w) == 1 - assert issubclass(w[-1].category, GeoRSSPolygonInteriorWarning) + self.assertEqual(len(w), 1) + self.assertTrue(issubclass(w[-1].category, + GeoRSSPolygonInteriorWarning)) self.assertEqual(fe.geo.polygon(), str(self.polygon_with_interior)) diff --git a/tests/test_extensions/test_media.py b/tests/test_extensions/test_media.py index 7fd9e40..1f5a349 100644 --- a/tests/test_extensions/test_media.py +++ b/tests/test_extensions/test_media.py @@ -32,21 +32,21 @@ def test_media_content(self): root = etree.fromstring(self.fg.rss_str()) url = root.xpath('/rss/channel/item/media:group/media:content[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) # There is one without a group url = root.xpath('/rss/channel/item/media:content[1]/@url', namespaces=ns) - assert url == ['file.xy'] + self.assertEqual(url, ['file.xy']) # Check that we have the item in the resulting Atom feed root = etree.fromstring(self.fg.atom_str()) url = root.xpath('/a:feed/a:entry/media:group/media:content[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) fe.media.content(content=[], replace=True) - assert fe.media.content() == [] + self.assertEqual(fe.media.content(), []) def test_media_thumbnail(self): fe = self.fg.add_item() @@ -66,18 +66,18 @@ def test_media_thumbnail(self): url = root.xpath( '/rss/channel/item/media:group/media:thumbnail[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) # There is one without a group url = root.xpath('/rss/channel/item/media:thumbnail[1]/@url', namespaces=ns) - assert url == ['file.xy'] + self.assertEqual(url, ['file.xy']) # Check that we have the item in the resulting Atom feed root = etree.fromstring(self.fg.atom_str()) url = root.xpath('/a:feed/a:entry/media:group/media:thumbnail[1]/@url', namespaces=ns) - assert url == ['file1.xy', 'file1.xy'] + self.assertEqual(url, ['file1.xy', 'file1.xy']) fe.media.thumbnail(thumbnail=[], replace=True) - assert fe.media.thumbnail() == [] + self.assertEqual(fe.media.thumbnail(), []) diff --git a/tests/test_extensions/test_podcast.py b/tests/test_extensions/test_podcast.py index cb7085e..84c8c96 100644 --- a/tests/test_extensions/test_podcast.py +++ b/tests/test_extensions/test_podcast.py @@ -26,8 +26,8 @@ def test_category_new(self): cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', namespaces=ns) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' + self.assertEqual(cat[0], 'Technology') + self.assertEqual(scat[0], 'Podcasting') def test_category(self): self.fg.podcast.itunes_category('Technology', 'Podcasting') @@ -40,8 +40,8 @@ def test_category(self): cat = root.xpath('/rss/channel/itunes:category/@text', namespaces=ns) scat = root.xpath('/rss/channel/itunes:category/itunes:category/@text', namespaces=ns) - assert cat[0] == 'Technology' - assert scat[0] == 'Podcasting' + self.assertEqual(cat[0], 'Technology') + self.assertEqual(scat[0], 'Podcasting') def test_podcastItems(self): fg = self.fg @@ -53,20 +53,20 @@ def test_podcastItems(self): fg.podcast.itunes_subtitle('x') fg.podcast.itunes_summary('x') fg.podcast.itunes_type('episodic') - assert fg.podcast.itunes_author() == 'Lars Kiesow' - assert fg.podcast.itunes_block() == 'x' - assert fg.podcast.itunes_complete() == 'no' - assert fg.podcast.itunes_explicit() == 'no' - assert fg.podcast.itunes_image() == 'x.png' - assert fg.podcast.itunes_subtitle() == 'x' - assert fg.podcast.itunes_summary() == 'x' - assert fg.podcast.itunes_type() == 'episodic' + self.assertEqual(fg.podcast.itunes_author(), 'Lars Kiesow') + self.assertEqual(fg.podcast.itunes_block(), 'x') + self.assertEqual(fg.podcast.itunes_complete(), 'no') + self.assertEqual(fg.podcast.itunes_explicit(), 'no') + self.assertEqual(fg.podcast.itunes_image(), 'x.png') + self.assertEqual(fg.podcast.itunes_subtitle(), 'x') + self.assertEqual(fg.podcast.itunes_summary(), 'x') + self.assertEqual(fg.podcast.itunes_type(), 'episodic') # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} root = etree.fromstring(self.fg.rss_str()) author = root.xpath('/rss/channel/itunes:author/text()', namespaces=ns) - assert author == ['Lars Kiesow'] + self.assertEqual(author, ['Lars Kiesow']) def test_podcastEntryItems(self): fe = self.fg.add_item() @@ -84,23 +84,23 @@ def test_podcastEntryItems(self): fe.podcast.itunes_episode(1) fe.podcast.itunes_title('Podcast Title') fe.podcast.itunes_episode_type('full') - assert fe.podcast.itunes_author() == 'Lars Kiesow' - assert fe.podcast.itunes_block() == 'x' - assert fe.podcast.itunes_duration() == '00:01:30' - assert fe.podcast.itunes_explicit() == 'no' - assert fe.podcast.itunes_image() == 'x.png' - assert fe.podcast.itunes_is_closed_captioned() - assert fe.podcast.itunes_order() == 1 - assert fe.podcast.itunes_subtitle() == 'x' - assert fe.podcast.itunes_summary() == 'x' - assert fe.podcast.itunes_season() == 1 - assert fe.podcast.itunes_episode() == 1 - assert fe.podcast.itunes_title() == 'Podcast Title' - assert fe.podcast.itunes_episode_type() == 'full' + self.assertEqual(fe.podcast.itunes_author(), 'Lars Kiesow') + self.assertEqual(fe.podcast.itunes_block(), 'x') + self.assertEqual(fe.podcast.itunes_duration(), '00:01:30') + self.assertEqual(fe.podcast.itunes_explicit(), 'no') + self.assertEqual(fe.podcast.itunes_image(), 'x.png') + self.assertTrue(fe.podcast.itunes_is_closed_captioned()) + self.assertEqual(fe.podcast.itunes_order(), 1) + self.assertEqual(fe.podcast.itunes_subtitle(), 'x') + self.assertEqual(fe.podcast.itunes_summary(), 'x') + self.assertEqual(fe.podcast.itunes_season(), 1) + self.assertEqual(fe.podcast.itunes_episode(), 1) + self.assertEqual(fe.podcast.itunes_title(), 'Podcast Title') + self.assertEqual(fe.podcast.itunes_episode_type(), 'full') # Check that we have the item in the resulting XML ns = {'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd'} root = etree.fromstring(self.fg.rss_str()) author = root.xpath('/rss/channel/item/itunes:author/text()', namespaces=ns) - assert author == ['Lars Kiesow'] + self.assertEqual(author, ['Lars Kiesow']) diff --git a/tests/test_extensions/test_syndication.py b/tests/test_extensions/test_syndication.py index 7a187d7..029e100 100644 --- a/tests/test_extensions/test_syndication.py +++ b/tests/test_extensions/test_syndication.py @@ -22,7 +22,7 @@ def test_update_period(self): root = etree.fromstring(self.fg.rss_str()) a = root.xpath('/rss/channel/sy:UpdatePeriod', namespaces=self.SYN_NS) - assert a[0].text == period_type + self.assertEqual(a[0].text, period_type) def test_update_frequency(self): for frequency in (1, 100, 2000, 100000): @@ -30,11 +30,11 @@ def test_update_frequency(self): root = etree.fromstring(self.fg.rss_str()) a = root.xpath('/rss/channel/sy:UpdateFrequency', namespaces=self.SYN_NS) - assert a[0].text == str(frequency) + self.assertEqual(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=self.SYN_NS) - assert a[0].text == base + self.assertEqual(a[0].text, base) diff --git a/tests/test_extensions/test_torrent.py b/tests/test_extensions/test_torrent.py index e996fde..230ec1a 100644 --- a/tests/test_extensions/test_torrent.py +++ b/tests/test_extensions/test_torrent.py @@ -23,16 +23,16 @@ def test_podcastEntryItems(self): fe.torrent.seeds('1') fe.torrent.peers('2') fe.torrent.verified('1') - assert fe.torrent.filename() == 'file.xy' - assert fe.torrent.infohash() == '123' - assert fe.torrent.contentlength() == '23' - assert fe.torrent.seeds() == '1' - assert fe.torrent.peers() == '2' - assert fe.torrent.verified() == '1' + self.assertEqual(fe.torrent.filename(), 'file.xy') + self.assertEqual(fe.torrent.infohash(), '123') + self.assertEqual(fe.torrent.contentlength(), '23') + self.assertEqual(fe.torrent.seeds(), '1') + self.assertEqual(fe.torrent.peers(), '2') + self.assertEqual(fe.torrent.verified(), '1') # Check that we have the item in the resulting XML ns = {'torrent': 'http://xmlns.ezrss.it/0.1/dtd/'} root = etree.fromstring(self.fg.rss_str()) filename = root.xpath('/rss/channel/item/torrent:filename/text()', namespaces=ns) - assert filename == ['file.xy'] + self.assertEqual(filename, ['file.xy']) diff --git a/tests/test_feed.py b/tests/test_feed.py index fcdbd4a..d09014d 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -117,22 +117,22 @@ def setUp(self): def test_baseFeed(self): fg = self.fg - assert fg.id() == self.feedId - assert fg.title() == self.title + self.assertEqual(fg.id(), self.feedId) + self.assertEqual(fg.title(), self.title) - assert fg.author()[0]['name'] == self.authorName - assert fg.author()[0]['email'] == self.authorMail + self.assertEqual(fg.author()[0]['name'], self.authorName) + self.assertEqual(fg.author()[0]['email'], self.authorMail) - assert fg.link()[0]['href'] == self.linkHref - assert fg.link()[0]['rel'] == self.linkRel + self.assertEqual(fg.link()[0]['href'], self.linkHref) + self.assertEqual(fg.link()[0]['rel'], self.linkRel) - assert fg.logo() == self.logo - assert fg.subtitle() == self.subtitle + self.assertEqual(fg.logo(), self.logo) + self.assertEqual(fg.subtitle(), self.subtitle) - assert fg.link()[1]['href'] == self.link2Href - assert fg.link()[1]['rel'] == self.link2Rel + self.assertEqual(fg.link()[1]['href'], self.link2Href) + self.assertEqual(fg.link()[1]['rel'], self.link2Rel) - assert fg.language() == self.language + self.assertEqual(fg.language(), self.language) def test_atomFeedFile(self): fg = self.fg @@ -178,10 +178,10 @@ def test_rel_values_for_atom(self): nsAtom = self.nsAtom feed_links = feed.findall("{%s}link" % nsAtom) idx = 0 - assert len(links) == len(feed_links) + self.assertEqual(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'] + self.assertEqual(feed_links[idx].get('href'), links[idx]['href']) + self.assertEqual(feed_links[idx].get('rel'), links[idx]['rel']) idx += 1 def test_rel_values_for_rss(self): @@ -212,49 +212,85 @@ def test_rel_values_for_rss(self): 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' + self.assertEqual(len(atom_links), 1) + self.assertEqual(atom_links[0].get('href'), + '%s/%s' % (self.linkHref, 'self')) + self.assertEqual(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('-', '_')) + self.assertEqual(len(rss_links), 1) + self.assertEqual( + rss_links[0].text, + '%s/%s' % (self.linkHref, 'working-copy-of'.replace('-', '_'))) def checkAtomString(self, atomString): feed = etree.fromstring(atomString) - 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 + nsAtom = "{" + self.nsAtom + "}" + + print(nsAtom) + print(f"{nsAtom}title") + testcases = [ + ( + feed.find(f"{nsAtom}title").text, + self.title + ), ( + feed.find(f"{nsAtom}id").text, + self.feedId + ), ( + feed.find(f"{nsAtom}category").get('term'), + self.categoryTerm + ), ( + feed.find(f"{nsAtom}category").get('label'), + self.categoryLabel + ), ( + feed.find(f"{nsAtom}author").find(f"{nsAtom}name").text, + self.authorName + ), ( + feed.find(f"{nsAtom}author").find(f"{nsAtom}email").text, + self.authorMail + ), ( + feed.findall(f"{nsAtom}link")[0].get('href'), + self.linkHref + ), ( + feed.findall(f"{nsAtom}link")[0].get('rel'), + self.linkRel + ), ( + feed.findall(f"{nsAtom}link")[1].get('href'), + self.link2Href + ), ( + feed.findall(f"{nsAtom}link")[1].get('rel'), + self.link2Rel + ), ( + feed.find(f"{nsAtom}logo").text, + self.logo + ), ( + feed.find(f"{nsAtom}icon").text, + self.icon + ), ( + feed.find(f"{nsAtom}subtitle").text, + self.subtitle + ), ( + feed.find(f"{nsAtom}contributor").find(f"{nsAtom}name").text, + self.contributor['name'] + ), ( + feed.find(f"{nsAtom}contributor").find(f"{nsAtom}email").text, + self.contributor['email'] + ), ( + feed.find(f"{nsAtom}contributor").find(f"{nsAtom}uri").text, + self.contributor['uri'] + ), ( + feed.find(f"{nsAtom}rights").text, + self.copyright + )] + for actual, expected in testcases: + self.assertEqual(actual, expected) + + self.assertIsNot( + feed.find(f"{nsAtom}updated").text, + None) def test_rssFeedFile(self): fg = self.fg @@ -301,39 +337,64 @@ def checkRssString(self, rssString): nsAtom = self.nsAtom ch = feed.find("channel") - assert ch is not 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 + self.assertIsNot(ch, None) + + self.assertEqual(ch.find("title").text, + self.title) + self.assertEqual(ch.find("description").text, + self.subtitle) + self.assertIsNot(ch.find("lastBuildDate").text, + None) + self.assertEqual(ch.find("docs").text, + "http://www.rssboard.org/rss-specification") + self.assertEqual(ch.find("generator").text, + "python-feedgen") + self.assertEqual(ch.findall("{%s}link" % nsAtom)[0].get('href'), + self.link2Href) + self.assertEqual(ch.findall("{%s}link" % nsAtom)[0].get('rel'), + self.link2Rel) + self.assertEqual(ch.find("image").find("url").text, + self.logo) + self.assertEqual(ch.find("image").find("title").text, + self.title) + self.assertEqual(ch.find("image").find("link").text, + self.link2Href) + self.assertEqual(ch.find("category").text, + self.categoryLabel) + self.assertEqual(ch.find("cloud").get('domain'), + self.cloudDomain) + self.assertEqual(ch.find("cloud").get('port'), + self.cloudPort) + self.assertEqual(ch.find("cloud").get('path'), + self.cloudPath) + self.assertEqual(ch.find("cloud").get('registerProcedure'), + self.cloudRegisterProcedure) + self.assertEqual(ch.find("cloud").get('protocol'), + self.cloudProtocol) + self.assertEqual(ch.find("copyright").text, + self.copyright) + self.assertEqual(ch.find("docs").text, + self.docs) + self.assertEqual(ch.find("managingEditor").text, + self.managingEditor) + self.assertEqual(ch.find("rating").text, + self.rating) + self.assertEqual(ch.find("skipDays").find("day").text, + self.skipDays) + self.assertEqual(int(ch.find("skipHours").find("hour").text), + self.skipHours) + self.assertEqual(ch.find("textInput").get('title'), + self.textInputTitle) + self.assertEqual(ch.find("textInput").get('description'), + self.textInputDescription) + self.assertEqual(ch.find("textInput").get('name'), + self.textInputName) + self.assertEqual(ch.find("textInput").get('link'), + self.textInputLink) + self.assertEqual(int(ch.find("ttl").text), + self.ttl) + self.assertEqual(ch.find("webMaster").text, + self.webMaster) if __name__ == '__main__': diff --git a/tests/test_main.py b/tests/test_main.py index af0b19e..af7e981 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -16,19 +16,15 @@ class TestSequenceFunctions(unittest.TestCase): def test_usage(self): sys.argv = ['feedgen'] - try: + with self.assertRaises(SystemExit) as e: __main__.main() - except BaseException as e: - assert e.code is None + self.assertEqual(e.exception.code, None) def test_feed(self): for ftype in 'rss', 'atom', 'podcast', 'torrent', 'dc.rss', \ 'dc.atom', 'syndication.rss', 'syndication.atom': sys.argv = ['feedgen', ftype] - try: - __main__.main() - except Exception: - assert False + __main__.main() def test_file(self): for extemsion in '.atom', '.rss': @@ -36,7 +32,6 @@ def test_file(self): sys.argv = ['feedgen', filename] try: __main__.main() - except Exception: - assert False - os.close(fh) - os.remove(filename) + finally: + os.close(fh) + os.remove(filename) From 51d743f5d9d3a21356ad4e7fe58075f86d934c28 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 22 Dec 2023 11:22:23 +0100 Subject: [PATCH 91/96] Allow integer to be used for enclosure length This patch allows integer to be used for specifying the enclosure length instead of just allowing string values. This is certainly the more natural choice for something representing a number. With this patch string values will continue to work. This fixes #104 --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 71d295a..f7a13a8 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -668,7 +668,7 @@ def enclosure(self, url=None, length=None, type=None): :returns: Data of the enclosure element. ''' if url is not None: - self.link(href=url, rel='enclosure', type=type, length=length) + self.link(href=url, rel='enclosure', type=type, length=str(length)) return self.__rss_enclosure def ttl(self, ttl=None): From 7110f3bbb7b42f6fa4b40b61c3d403e0ea031265 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 22 Dec 2023 23:12:53 +0100 Subject: [PATCH 92/96] Fixed category documentation This patch fixes a minor issue in the documentation of the feed's `category(...)` method. --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 1a626c5..0450304 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -635,7 +635,7 @@ def category(self, category=None, replace=False, **kwargs): 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 category: Dict or list of dicts with data. :param replace: Add or replace old data. :returns: List of category data. ''' From b4999f47619f8939ed280628eb5ebbfde2f2da80 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 24 Dec 2023 11:43:47 +0100 Subject: [PATCH 93/96] Fixex generating Atom feed when adding description as summary This patch fixes the problem that generating an Atom feed fails when adding an RSS description marked as summary. The problem was that feedgen internally expected a dictionary, but the value was stored as a string in this case. This fixes #94 --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index f7a13a8..5dd21c2 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -499,7 +499,7 @@ def description(self, description=None, isSummary=False): if description is not None: self.__rss_description = description if isSummary: - self.__atom_summary = description + self.__atom_summary = {'summary': description} else: self.__atom_content = {'content': description} return self.__rss_description From cf6af3d1095800b8352e4f6d931c292994eb6062 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 24 Dec 2023 15:36:17 +0100 Subject: [PATCH 94/96] Include tests in release tarball This patch ensures the tests ader added to the release tarballs of upcoming releases. This fixes #98 --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index c6c12dc..a7c625e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include license.bsd license.lgpl readme.rst recursive-include docs *.html *.css *.png *.gif *.js +recursive-include tests *.py From fc5b64a2453fccfee4d2036d2f26578c5f7c7f41 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 25 Dec 2023 18:51:18 +0100 Subject: [PATCH 95/96] Update RPM Specfile (tests, pypi, py3) This patch updates the RPM specfile to make sure it uses modern macros used for Python packaging. It also switches to the tarballs published on PyPi which will include the tests in future versions. Finally, this drops support of Python 2 which has reached end of life quite a while ago. --- python-feedgen.spec | 53 +++++++++++++++------------------------------ 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/python-feedgen.spec b/python-feedgen.spec index b2e6515..a137396 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,38 +1,24 @@ %global pypi_name feedgen +%global pypi_version 1.0.0 Name: python-%{pypi_name} -Version: 0.9.0 +Version: %{pypi_version} Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) License: BSD or LGPLv3 URL: http://lkiesow.github.io/python-feedgen -Source0: https://github.com/lkiesow/%{name}/archive/v%{version}.tar.gz +#Source0: https://github.com/lkiesow/%{name}/archive/v%{version}.tar.gz +Source0: %{pypi_source} BuildArch: noarch -BuildRequires: python2-dateutil -BuildRequires: python2-devel -BuildRequires: python2-lxml -BuildRequires: python2-setuptools - -BuildRequires: python3-dateutil BuildRequires: python3-devel -BuildRequires: python3-lxml -BuildRequires: python3-setuptools +BuildRequires: python3dist(setuptools) +BuildRequires: python3dist(lxml) +BuildRequires: python3dist(python-dateutil) %description -Feedgenerator 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. - -%package -n python2-%{pypi_name} -Summary: %{summary} -%{?python_provide:%python_provide python2-%{pypi_name}} - -Requires: python2-dateutil -Requires: python2-lxml -%description -n python2-%{pypi_name} -Feedgenerator This module can be used to generate web feeds in both ATOM and +Feedgenerator: 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. @@ -40,38 +26,29 @@ to produce Podcasts. Summary: %{summary} %{?python_provide:%python_provide python3-%{pypi_name}} -Requires: python3-dateutil -Requires: python3-lxml +Requires: python3dist(python-dateutil) +Requires: python3dist(lxml) + %description -n python3-%{pypi_name} -Feedgenerator This module can be used to generate web feeds in both ATOM and +Feedgenerator: 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. %prep -%autosetup +%autosetup -n %{pypi_name}-%{pypi_version} # Remove bundled egg-info rm -rf %{pypi_name}.egg-info %build -%py2_build %py3_build %install -%py2_install %py3_install - %check -%{__python2} setup.py test %{__python3} setup.py test -%files -n python2-%{pypi_name} -%license license.lgpl license.bsd -%doc readme.rst -%{python2_sitelib}/%{pypi_name} -%{python2_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info - %files -n python3-%{pypi_name} %license license.lgpl license.bsd %doc readme.rst @@ -79,6 +56,10 @@ rm -rf %{pypi_name}.egg-info %{python3_sitelib}/%{pypi_name}-%{version}-py?.?.egg-info %changelog +* Mon Dec 25 2023 Lars Kiesow - 1.0.0-1 +- Update to 1.0.0 +- Removing support for Python 2 + * Sat May 19 2018 Lars Kiesow - 0.7.0-1 - Update to 0.7.0 From 97260abb1793eb164c458c10b493690beb413f6d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 25 Dec 2023 18:59:03 +0100 Subject: [PATCH 96/96] Release 1.0.0 --- feedgen/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/version.py b/feedgen/version.py index 2a59ec0..c8f76bf 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 9, 0) +version = (1, 0, 0) 'Version of python-feedgen represented as string'