From cddff68f37b8633047c684a37c9d0c0aeaa76c1f Mon Sep 17 00:00:00 2001 From: wltb Date: Mon, 12 May 2014 18:53:39 +0200 Subject: [PATCH 001/159] dc: Centralize XML extension --- feedgen/ext/dc.py | 78 ++++++++++++++++++----------------------------- 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index c4450a8..9476d70 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -45,57 +45,50 @@ def __init__(self): def extend_ns(self): return {'dc' : 'http://purl.org/dc/elements/1.1/'} - def extend_atom(self, atom_feed): - '''Create an Atom feed xml structure containing all previously set fields. + def _extend_xml(self, xml_elem): + '''Extend xml_elem with set DC fields. - :returns: The feed root element + :param xml_elem: etree element ''' DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/' - feed = atom_feed - for elem in ['contributor', 'coverage', 'creator', 'date', 'description', - 'language', 'publisher', 'relation', 'rights', 'source', 'subject', - 'title', 'type']: + 'language', 'publisher', 'relation', 'rights', 'source', 'subject', + 'title', 'type']: if hasattr(self, '_dcelem_%s' % elem): for val in getattr(self, '_dcelem_%s' % elem) or []: - node = etree.SubElement(feed, '{%s}%s' % (DCELEMENTS_NS,elem)) + node = etree.SubElement(xml_elem, '{%s}%s' % (DCELEMENTS_NS, elem)) node.text = val if self._dcelem_format: - node = etree.SubElement(feed, '{%s}format' % DCELEMENTS_NS) + node = etree.SubElement(xml_elem, '{%s}format' % DCELEMENTS_NS) node.text = format if self._dcelem_identifier: - node = etree.SubElement(feed, '{%s}identifier' % DCELEMENTS_NS) + node = etree.SubElement(xml_elem, '{%s}identifier' % DCELEMENTS_NS) node.text = identifier - return feed + 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 + ''' - def extend_rss(self, rss_feed): - '''Create an RSS feed xml structure containing all previously set fields. + self._extend_xml(atom_feed) - :returns: Tuple containing the feed root element and the element tree. - ''' - DCELEMENTS_NS = 'http://purl.org/dc/elements/1.1/' - channel = rss_feed[0] + return atom_feed - for elem in ['contributor', 'coverage', 'creator', 'date', 'description', - 'language', 'publisher', 'relation', 'rights', 'source', 'subject', - 'title', 'type']: - if hasattr(self, '_dcelem_%s' % elem): - for val in getattr(self, '_dcelem_%s' % elem) or []: - node = etree.SubElement(channel, '{%s}%s' % (DCELEMENTS_NS,elem)) - node.text = val - if self._dcelem_format: - node = etree.SubElement(channel, '{%s}format' % DCELEMENTS_NS) - node.text = format - if self._dcelem_identifier: - node = etree.SubElement(channel, '{%s}identifier' % DCELEMENTS_NS) - node.text = identifier + 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 @@ -418,32 +411,19 @@ class DcEntryExtension(DcBaseExtension): '''Dublin Core Elements extension for podcasts. ''' def extend_atom(self, entry): - '''NYI. Differs from RSS Implementation? + '''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. ''' - 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']: - if hasattr(self, '_dcelem_%s' % elem): - for val in getattr(self, '_dcelem_%s' % elem) or []: - node = etree.SubElement(item, '{%s}%s' % (DCELEMENTS_NS,elem)) - node.text = val - - if self._dcelem_format: - node = etree.SubElement(item, '{%s}format' % DCELEMENTS_NS) - node.text = format - - if self._dcelem_identifier: - node = etree.SubElement(item, '{%s}identifier' % DCELEMENTS_NS) - node.text = identifier - + self._extend_xml(item) return item From eab1f874f2ac0dcd97f53703d0c1fe69110a644b Mon Sep 17 00:00:00 2001 From: wltb Date: Mon, 12 May 2014 19:04:55 +0200 Subject: [PATCH 002/159] podcast: Update docstring, remove legacy x=x code --- feedgen/ext/podcast.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 14a8a95..616473e 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -39,13 +39,12 @@ def extend_ns(self): def extend_rss(self, rss_feed): - '''Create an RSS feed xml structure containing all previously set fields. + '''Extend an RSS feed root with set itunes fields. - :returns: Tuple containing the feed root element and the element tree. + :returns: The feed root element. ''' ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd' - feed = rss_feed - channel = feed[0] + channel = rss_feed[0] if self.__itunes_author: author = etree.SubElement(channel, '{%s}author' % ITUNES_NS) @@ -93,7 +92,7 @@ def extend_rss(self, rss_feed): summary = etree.SubElement(channel, '{%s}summary' % ITUNES_NS) summary.text = self.__itunes_summary - return feed + return rss_feed def itunes_author(self, itunes_author=None): From 90ab565a4a358352c94e194362ac66ac640b3a8b Mon Sep 17 00:00:00 2001 From: snipem Date: Sun, 1 Jun 2014 00:27:35 +0200 Subject: [PATCH 003/159] Unit test for feed creation Tests feed object, atom and rss string --- feedgen/tests/test_feed.py | 116 +++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 feedgen/tests/test_feed.py diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py new file mode 100644 index 0000000..071e2fd --- /dev/null +++ b/feedgen/tests/test_feed.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +""" +Tests for a basic feed + +These test cases contain test cases for a basic feed. A basic feed does not contain entries so far. +""" + +import unittest +from lxml import etree + +class TestSequenceFunctions(unittest.TestCase): + + def setUp(self): + from feedgen.feed import FeedGenerator + fg = FeedGenerator() + + 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' + + 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) + + 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_atomFeed(self): + fg = self.fg + + atomString = fg.atom_str(pretty=True) + feed = etree.fromstring(atomString) + + nsAtom = "http://www.w3.org/2005/Atom" + + 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}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}subtitle" % nsAtom).text == self.subtitle + + def test_rssFeed(self): + fg = self.fg + + rssString = fg.rss_str(pretty=True) + feed = etree.fromstring(rssString) + + nsAtom = "http://www.w3.org/2005/Atom" + nsRss = "http://purl.org/rss/1.0/modules/content/" + + 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 + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From d2bbc87f6c0dd5772bd74360e57530d9b0ab6847 Mon Sep 17 00:00:00 2001 From: snipem Date: Sun, 1 Jun 2014 13:39:37 +0200 Subject: [PATCH 004/159] Added test cases for all elements and travis support --- .gitignore | 4 ++ .travis.yml | 10 ++++ feedgen/tests/test_entry.py | 83 ++++++++++++++++++++++++++ feedgen/tests/test_feed.py | 115 ++++++++++++++++++++++++++++++++---- 4 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 .travis.yml create mode 100644 feedgen/tests/test_entry.py diff --git a/.gitignore b/.gitignore index ce30c5f..acc4484 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ venv *.pyc *.pyo *.swp + +feedgen/tests/tmp_Atomfeed.xml + +feedgen/tests/tmp_Rssfeed.xml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e3224ad --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: python +python: + - "2.6" + - "2.7" + - "3.2" + - "3.3" +# command to install dependencies +install: "pip install lxml" +# command to run tests +script: py.test \ No newline at end of file diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py new file mode 100644 index 0000000..38023d3 --- /dev/null +++ b/feedgen/tests/test_entry.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- + +""" +Tests for a basic feed + +These test cases contain test cases for a basic feed. A basic feed does not contain entries so far. +""" + +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 + + + + + diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 071e2fd..12b51ee 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -15,6 +15,9 @@ def setUp(self): from feedgen.feed import FeedGenerator 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' @@ -32,6 +35,34 @@ def setUp(self): 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) @@ -41,9 +72,23 @@ def setUp(self): 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 @@ -64,37 +109,73 @@ def test_baseFeed(self): assert fg.language() == self.language - def test_atomFeed(self): + def test_atomFeedFile(self): fg = self.fg + filename = 'tmp_Atomfeed.xml' + fg.atom_file(filename=filename, pretty=True) + + 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) + self.checkAtomString(atomString) + + + def checkAtomString(self, atomString): + feed = etree.fromstring(atomString) - nsAtom = "http://www.w3.org/2005/Atom" + print (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}url" % 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) + + with open (filename, "r") as myfile: + rssString=myfile.read().replace('\n', '') + + self.checkRssString(rssString) - def test_rssFeed(self): + def test_rssFeedString(self): fg = self.fg rssString = fg.rss_str(pretty=True) - feed = etree.fromstring(rssString) + self.checkRssString(rssString) - nsAtom = "http://www.w3.org/2005/Atom" - nsRss = "http://purl.org/rss/1.0/modules/content/" + def test_loadExtension(self): + raise Exception('Not yet implemented') + + def checkRssString(self, rssString): + + feed = etree.fromstring(rssString) + nsAtom = self.nsAtom + nsRss = self.nsRss channel = feed.find("channel") assert channel != None @@ -104,13 +185,25 @@ def test_rssFeed(self): 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").text == self.cloud + 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 channel.find("ttl").text == self.ttl + assert channel.find("webMaster").text == self.webMaster if __name__ == '__main__': unittest.main() \ No newline at end of file From da06835e8bd99a9c996719ad47efcf23b9b7e0e6 Mon Sep 17 00:00:00 2001 From: snipem Date: Mon, 2 Jun 2014 22:45:15 +0200 Subject: [PATCH 005/159] Added unit test cases for extension loading Also fixed bugs regarding the upcoming feed.py fix --- .gitignore | 4 ++++ feedgen/tests/__init__.py | 0 feedgen/tests/test_entry.py | 7 +++---- feedgen/tests/test_extension.py | 0 feedgen/tests/test_feed.py | 23 +++++++++++++++++------ 5 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 feedgen/tests/__init__.py create mode 100644 feedgen/tests/test_extension.py diff --git a/.gitignore b/.gitignore index acc4484..b6f6775 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ venv feedgen/tests/tmp_Atomfeed.xml feedgen/tests/tmp_Rssfeed.xml + +tmp_Atomfeed.xml + +tmp_Rssfeed.xml diff --git a/feedgen/tests/__init__.py b/feedgen/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 38023d3..19186a5 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -1,15 +1,14 @@ # -*- coding: utf-8 -*- """ -Tests for a basic feed +Tests for a basic entry -These test cases contain test cases for a basic feed. A basic feed does not contain entries so far. +These test cases contain test cases for a basic entry. """ import unittest from lxml import etree -from feedgen.feed import FeedGenerator - +from ..feed import FeedGenerator class TestSequenceFunctions(unittest.TestCase): diff --git a/feedgen/tests/test_extension.py b/feedgen/tests/test_extension.py new file mode 100644 index 0000000..e69de29 diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 12b51ee..79f144b 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -8,11 +8,12 @@ import unittest from lxml import etree +from ..feed import FeedGenerator class TestSequenceFunctions(unittest.TestCase): def setUp(self): - from feedgen.feed import FeedGenerator + fg = FeedGenerator() self.nsAtom = "http://www.w3.org/2005/Atom" @@ -164,12 +165,16 @@ def test_rssFeedFile(self): def test_rssFeedString(self): fg = self.fg - rssString = fg.rss_str(pretty=True) self.checkRssString(rssString) - def test_loadExtension(self): - raise Exception('Not yet implemented') + def test_loadPodcastExtension(self): + fg = self.fg + fg.load_extension('podcast', atom=True, rss=True) + + def test_loadDcExtension(self): + fg = self.fg + fg.load_extension('dc', atom=True, rss=True) def checkRssString(self, rssString): @@ -177,6 +182,8 @@ def checkRssString(self, rssString): nsAtom = self.nsAtom nsRss = self.nsRss + print (rssString) + channel = feed.find("channel") assert channel != None @@ -191,7 +198,11 @@ def checkRssString(self, rssString): 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").text == self.cloud + 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 @@ -202,7 +213,7 @@ def checkRssString(self, rssString): 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 channel.find("ttl").text == self.ttl + assert int(channel.find("ttl").text) == self.ttl assert channel.find("webMaster").text == self.webMaster if __name__ == '__main__': From 9213c3057e498b4abc8568165b287fa3e113ef80 Mon Sep 17 00:00:00 2001 From: snipem Date: Mon, 2 Jun 2014 22:47:45 +0200 Subject: [PATCH 006/159] Fix domain and ttl bugs for RSS generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the context of RSS cloud domain was spelled incorrectly as ‚donain‘ which resulted in an error while unit testing. Additionally, also in RSS ttl (Time to live) wasn’t transformed to a string. --- feedgen/feed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 315d109..e78e502 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -342,7 +342,7 @@ def _create_rss(self, extensions=True): textInput.attrib['link'] = self.__rss_textInput.get('link') if self.__rss_ttl: ttl = etree.SubElement(channel, 'ttl') - ttl.text = self.__rss_ttl + ttl.text = str(self.__rss_ttl) if self.__rss_webMaster: webMaster = etree.SubElement(channel, 'webMaster') webMaster.text = self.__rss_webMaster @@ -626,7 +626,7 @@ def cloud(self, domain=None, port=None, path=None, registerProcedure=None, :returns: Dictionary containing the cloud data. ''' if not domain is None: - self.__rss_cloud = {'donain':domain, 'port':port, 'path':path, + self.__rss_cloud = {'domain':domain, 'port':port, 'path':path, 'registerProcedure':registerProcedure, 'protocol':protocol} return self.__rss_cloud From a6300a13abb06e8a15bc69e083c8696ef23b4f93 Mon Sep 17 00:00:00 2001 From: snipem Date: Mon, 2 Jun 2014 22:55:25 +0200 Subject: [PATCH 007/159] Added dateutil requirement for travis python-dateutil was missing for a clean install of the module --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e3224ad..3d13151 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,6 @@ python: - "3.2" - "3.3" # command to install dependencies -install: "pip install lxml" +install: "pip install lxml python-dateutil" # command to run tests script: py.test \ No newline at end of file From 0907e993e4dc38f0f5e08c6b3555fd68e18caf4a Mon Sep 17 00:00:00 2001 From: snipem Date: Mon, 2 Jun 2014 23:08:50 +0200 Subject: [PATCH 008/159] Fix skipHours for Python 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The xrange function isn’t available in Python 3. The range function however is doing the same job and is working for both Python 2 and Python 3. --- feedgen/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index e78e502..194ec5c 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -881,7 +881,7 @@ def skipHours(self, hours=None, replace=False): if not (isinstance(hours, list) or isinstance(hours, set)): hours = [hours] for h in hours: - if not h in xrange(24): + if not h in range(24): raise ValueError('Invalid hour %s' % h) if replace or not self.__rss_skipHours: self.__rss_skipHours = set() From 5353f3ede535a69b46c633ef7fbf18426f407bf4 Mon Sep 17 00:00:00 2001 From: Shekhar Date: Fri, 20 Jun 2014 10:05:34 +0530 Subject: [PATCH 009/159] support for CDATA content If type specified CDATA content won't be escaped. This makes it easier for RSS publishers and readers. Also adheres CDATA recommendation at http://www.rssboard.org/rss-profile#element-channel-item-description --- feedgen/entry.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 499c387..b1763f1 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -102,6 +102,8 @@ def atom_entry(self, extensions=True): content.append(etree.fromstring('''
%s
''' % \ self.__atom_content.get('content'))) + elif type == 'CDATA': + content.text = etree.CDATA(self.__atom_content) # Emed the text in escaped form elif not type or type.startswith('text') or type == 'html': content.text = self.__atom_content.get('content') @@ -187,13 +189,14 @@ def rss_entry(self, extensions=True): description.text = self.__rss_description content = etree.SubElement(entry, '{%s}encoded' % 'http://purl.org/rss/1.0/modules/content/') - content.text = self.__rss_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 + description.text = self.__rss_content['content'] for a in self.__rss_author or []: author = etree.SubElement(entry, 'author') author.text = a @@ -345,15 +348,17 @@ def content(self, content=None, src=None, type=None): :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 = content + self.__rss_content['type'] = type return self.__atom_content From ae760d0463bee74d189a99455290e29fadf73173 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 20 Jul 2014 22:19:17 +0200 Subject: [PATCH 010/159] Fixed some fields of the DC extension Signed-off-by: Lars Kiesow --- feedgen/__main__.py | 4 +++- feedgen/ext/dc.py | 16 +++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 833a897..158c66f 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -31,7 +31,9 @@ def print_enc(s): 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 -- Generator Podcast test output and print it to stdout.') + 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 ('') exit() diff --git a/feedgen/ext/dc.py b/feedgen/ext/dc.py index 9476d70..69835d2 100644 --- a/feedgen/ext/dc.py +++ b/feedgen/ext/dc.py @@ -53,20 +53,13 @@ def _extend_xml(self, xml_elem): 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']: + '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 - if self._dcelem_format: - node = etree.SubElement(xml_elem, '{%s}format' % DCELEMENTS_NS) - node.text = format - - if self._dcelem_identifier: - node = etree.SubElement(xml_elem, '{%s}identifier' % DCELEMENTS_NS) - node.text = identifier def extend_atom(self, atom_feed): '''Extend an Atom feed with the set DC fields. @@ -225,14 +218,11 @@ 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. - If not set, the value of atom:id will be used. But setting this value - will on the other hand not set atom:id. - 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 format (deault: True). + :param replace: Replace alredy set identifier (deault: True). :returns: Identifiers of the resource. ''' if not identifier is None: From ff1f40a6cd0014e0f0b12c788b3c1c42e9196873 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 20 Jul 2014 22:55:25 +0200 Subject: [PATCH 011/159] Fixing some small test issues This fixes some small issues with the unit tests and adding a test case to the Makefile so that all tests can easily be invoked by running: make test Signed-off-by: Lars Kiesow --- Makefile | 5 +++++ feedgen/tests/test_entry.py | 11 +++------- feedgen/tests/test_feed.py | 42 ++++++++++++++++++++----------------- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/Makefile b/Makefile index ff99d34..abd2a1e 100644 --- a/Makefile +++ b/Makefile @@ -42,3 +42,8 @@ doc-latexpdf: publish: sdist python setup.py register sdist upload + +test: + python -m unittest feedgen.tests.test_feed + python -m unittest feedgen.tests.test_entry + python -m unittest feedgen.tests.test_extension diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 19186a5..7b35d60 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -3,7 +3,7 @@ """ Tests for a basic entry -These test cases contain test cases for a basic entry. +These are test cases for a basic entry. """ import unittest @@ -13,7 +13,7 @@ class TestSequenceFunctions(unittest.TestCase): def setUp(self): - + fg = FeedGenerator() self.feedId = 'http://example.com' self.title = 'Some Testfeed' @@ -71,12 +71,7 @@ def test_removeEntryByEntry(self): 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 - - - - - diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 79f144b..757497c 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -3,7 +3,8 @@ """ Tests for a basic feed -These test cases contain test cases for a basic feed. A basic feed does not contain entries so far. +These are test cases for a basic feed. +A basic feed does not contain entries so far. """ import unittest @@ -21,7 +22,7 @@ def setUp(self): 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} @@ -36,7 +37,7 @@ def setUp(self): self.link2Rel = 'self' self.language = 'en' - + self.categoryTerm = 'This category term' self.categoryScheme = 'This category scheme' self.categoryLabel = 'This category label' @@ -48,7 +49,8 @@ def setUp(self): self.cloudProtocol = 'SOAP 1.1' self.icon = "http://example.com/icon.png" - self.contributor = {'name':"Contributor Name", 'uri':"Contributor Uri", 'email': 'Contributor email'} + 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' @@ -73,9 +75,12 @@ def setUp(self): 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.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.category(term=self.categoryTerm, scheme=self.categoryScheme, + label=self.categoryLabel) fg.contributor(self.contributor) fg.copyright(self.copyright) fg.docs(docs=self.docs) @@ -83,7 +88,9 @@ def setUp(self): 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.textInput(title=self.textInputTitle, + description=self.textInputDescription, name=self.textInputName, + link=self.textInputLink) fg.ttl(self.ttl) fg.webMaster(self.webMaster) @@ -114,15 +121,15 @@ def test_atomFeedFile(self): fg = self.fg filename = 'tmp_Atomfeed.xml' fg.atom_file(filename=filename, pretty=True) - + with open (filename, "r") as myfile: - atomString=myfile.read().replace('\n', '') - + atomString=myfile.read().replace('\n', '') + self.checkAtomString(atomString) def test_atomFeedString(self): fg = self.fg - + atomString = fg.atom_str(pretty=True) self.checkAtomString(atomString) @@ -131,7 +138,6 @@ def checkAtomString(self, atomString): feed = etree.fromstring(atomString) - print (atomString) nsAtom = self.nsAtom assert feed.find("{%s}title" % nsAtom).text == self.title @@ -157,10 +163,10 @@ def test_rssFeedFile(self): fg = self.fg filename = 'tmp_Rssfeed.xml' fg.rss_file(filename=filename, pretty=True) - + with open (filename, "r") as myfile: - rssString=myfile.read().replace('\n', '') - + rssString=myfile.read().replace('\n', '') + self.checkRssString(rssString) def test_rssFeedString(self): @@ -175,15 +181,13 @@ def test_loadPodcastExtension(self): def test_loadDcExtension(self): fg = self.fg fg.load_extension('dc', atom=True, rss=True) - + def checkRssString(self, rssString): feed = etree.fromstring(rssString) nsAtom = self.nsAtom nsRss = self.nsRss - print (rssString) - channel = feed.find("channel") assert channel != None @@ -217,4 +221,4 @@ def checkRssString(self, rssString): assert channel.find("webMaster").text == self.webMaster if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 8e0ed2df8bef19be2816978bff3dccd70201563c Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 20 Jul 2014 23:57:29 +0200 Subject: [PATCH 012/159] Release 0.3.0 - Fixed several minor bugs - Code cleanup of extensions (API change!) - Added unit tests Thanks to all contributors! 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 f6afdaa..e8bf584 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -3,14 +3,14 @@ feedgen.version ~~~~~~~~~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013-2014, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' 'Version of python-feedgen represented as tuple' -version = (0, 2, 8) +version = (0, 3, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 6930c1a..34453c5 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.2.8 +Version: 0.3.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -51,6 +51,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* 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 From 211aac6f28daa04821373a5e69274b8c0cca3e02 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 21 Jul 2014 00:34:07 +0200 Subject: [PATCH 013/159] Modified documentation for new release Signed-off-by: Lars Kiesow --- doc/api.rst | 1 + doc/ext/api.ext.dc.rst | 7 +++++++ readme.md | 8 +++----- 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 doc/ext/api.ext.dc.rst diff --git a/doc/api.rst b/doc/api.rst index be89075..6aca66e 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -14,5 +14,6 @@ Contents: api.entry api.util ext/api.ext.base + ext/api.ext.dc ext/api.ext.podcast ext/api.ext.podcast_entry diff --git a/doc/ext/api.ext.dc.rst b/doc/ext/api.ext.dc.rst new file mode 100644 index 0000000..7e01cf3 --- /dev/null +++ b/doc/ext/api.ext.dc.rst @@ -0,0 +1,7 @@ +.. raw:: html + +
Contents
+
+ +.. automodule:: feedgen.ext.dc + :members: diff --git a/readme.md b/readme.md index 6c751ef..9991cc6 100644 --- a/readme.md +++ b/readme.md @@ -26,10 +26,8 @@ Installation If you are running Fedora Linux, Redhat Enterprise Linux, CentOS or Scientific Linux you can use one of the following packages: -- [python-feedgen-0.2.8-1.fc20.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc20.noarch.rpm) -- [python-feedgen-0.2.8-1.fc19.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc19.noarch.rpm) -- [python-feedgen-0.2.8-1.fc18.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc18.noarch.rpm) -- [python-feedgen-0.2.8-1.el6.noarch.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.el6.noarch.rpm) +- [python-feedgen-0.3.0-1.fc20.noarch.rpm](http://repo.virtuos.uos.de/repository/feedgen/python-feedgen-0.3.0-1.fc20.noarch.rpm) +- [python-feedgen-0.3.0-1.el6.noarch.rpm](http://repo.virtuos.uos.de/repository/feedgen/python-feedgen-0.3.0-1.el6.noarch.rpm) Simply download the file and run:: @@ -37,7 +35,7 @@ Simply download the file and run:: If you want to build RPMs for other distributions you can use the following Source RPM: -- [python-feedgen-0.2.8-1.fc20.src.rpm](http://larskiesow.de/python-feedgen/python-feedgen-0.2.8-1.fc20.src.rpm) +- [python-feedgen-0.3.0-1.fc20.src.rpm](http://repo.virtuos.uos.de/repository/feedgen/python-feedgen-0.3.0-1.fc20.src.rpm) **Using pip** From f9c2b36233b73db0b08d008ff824b989878d7767 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 21 Jul 2014 00:40:50 +0200 Subject: [PATCH 014/159] Remove temporary test files Signed-off-by: Lars Kiesow --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index abd2a1e..b61b9b6 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ clean: doc-clean @echo Removing source distribution files... @rm -rf dist/ @rm -f MANIFEST + @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml doc: doc-clean doc-html doc-man doc-latexpdf @@ -47,3 +48,4 @@ test: python -m unittest feedgen.tests.test_feed python -m unittest feedgen.tests.test_entry python -m unittest feedgen.tests.test_extension + @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml From 3b821a43d60ac77ca84d0406a018d62d2cadfde0 Mon Sep 17 00:00:00 2001 From: Lionel Young Date: Thu, 1 Jan 2015 19:28:36 -0800 Subject: [PATCH 015/159] Update readme.md for podcast example Show the user how to create an entry with an enclosure --- readme.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/readme.md b/readme.md index 9991cc6..c50732e 100644 --- a/readme.md +++ b/readme.md @@ -142,6 +142,12 @@ To produce a podcast simply load the `podcast` extension:: ... >>> 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') + ... >>> fg.rss_str(pretty=True) >>> fg.rss_file('podcast.xml') From 6ac8191396d805e0a43ea298fc68f517bbd2a22f Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 3 Jan 2015 22:09:31 +0100 Subject: [PATCH 016/159] Update Travis-CI Configuration Signed-off-by: Lars Kiesow --- .travis.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3d13151..4ead684 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,11 @@ language: python + python: - "2.6" - "2.7" - "3.2" - "3.3" -# command to install dependencies -install: "pip install lxml python-dateutil" -# command to run tests -script: py.test \ No newline at end of file + +before_install: pip install lxml python-dateutil + +script: make test From be09fe7b658725717d5bfa1adddf014af2984217 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 3 Jan 2015 22:14:31 +0100 Subject: [PATCH 017/159] Be Less Noisy on Dependency Installation Signed-off-by: Lars Kiesow --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 4ead684..6824ca3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,6 @@ python: - "3.2" - "3.3" -before_install: pip install lxml python-dateutil +before_install: pip install --quiet lxml python-dateutil script: make test From 72b2896cf80b786a2db089f469adf9f538226b61 Mon Sep 17 00:00:00 2001 From: wltb Date: Sun, 6 Jul 2014 19:38:34 +0200 Subject: [PATCH 018/159] Add encoding argument for output creation functions, change default encoding to unicode --- feedgen/__main__.py | 2 +- feedgen/feed.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 158c66f..967d9fb 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -62,7 +62,7 @@ def print_enc(s): domesticam, aliam forensem, ut in fronte ostentatio sit, intus veritas occultetur? Cum id fugiunt, re eadem defendunt, quae Peripatetici, verba.''') - fe.summary('Lorem ipsum dolor sit amet, consectetur adipiscing elit...') + 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' ) diff --git a/feedgen/feed.py b/feedgen/feed.py index 194ec5c..9e3fc4c 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -199,7 +199,7 @@ def _create_atom(self, extensions=True): return feed, doc - def atom_str(self, pretty=False, extensions=True): + def atom_str(self, pretty=False, extensions=True, encoding="unicode"): '''Generates an ATOM feed and returns the feed XML as string. :param pretty: If the feed should be split into multiple lines and @@ -209,10 +209,10 @@ def atom_str(self, pretty=False, extensions=True): :returns: String representation of the ATOM feed. ''' feed, doc = self._create_atom(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty) + return etree.tostring(feed, pretty_print=pretty, encoding=encoding) - def atom_file(self, filename, extensions=True, pretty=False): + def atom_file(self, filename, extensions=True, pretty=False, encoding="UTF-8"): '''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. @@ -220,7 +220,7 @@ def atom_file(self, filename, extensions=True, pretty=False): generation (default: enabled). ''' feed, doc = self._create_atom(extensions=extensions) - doc.write(filename, pretty_print=pretty) + doc.write(filename, pretty_print=pretty, encoding=encoding) def _create_rss(self, extensions=True): @@ -360,7 +360,7 @@ def _create_rss(self, extensions=True): return feed, doc - def rss_str(self, pretty=False, extensions=True): + def rss_str(self, pretty=False, extensions=True, encoding="unicode"): '''Generates an RSS feed and returns the feed XML as string. :param pretty: If the feed should be split into multiple lines and @@ -370,10 +370,10 @@ def rss_str(self, pretty=False, extensions=True): :returns: String representation of the RSS feed. ''' feed, doc = self._create_rss(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty) + return etree.tostring(feed, pretty_print=pretty, encoding=encoding) - def rss_file(self, filename, extensions=True, pretty=False): + def rss_file(self, filename, extensions=True, pretty=False, encoding="UTF-8"): '''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. @@ -381,7 +381,7 @@ def rss_file(self, filename, extensions=True, pretty=False): generation (default: enabled). ''' feed, doc = self._create_rss(extensions=extensions) - doc.write(filename, pretty_print=pretty) + doc.write(filename, pretty_print=pretty, encoding=encoding) def title(self, title=None): From 626d9df6c4fc2dabdd508eae1378d48c359d961b Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Sat, 20 Sep 2014 23:51:04 +0200 Subject: [PATCH 019/159] Add Python 3 compatibility. --- feedgen/entry.py | 5 +++-- feedgen/feed.py | 9 +++++---- setup.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index b1763f1..00e4c94 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -13,6 +13,7 @@ import dateutil.parser import dateutil.tz from feedgen.util import ensure_format +from six import string_types class FeedEntry(object): @@ -282,7 +283,7 @@ def updated(self, updated=None): :returns: Modification date as datetime.datetime ''' if not updated is None: - if isinstance(updated, basestring): + if isinstance(updated, string_types): updated = dateutil.parser.parse(updated) if not isinstance(updated, datetime): raise ValueError('Invalid datetime format') @@ -549,7 +550,7 @@ def published(self, published=None): :returns: Creation date as datetime.datetime ''' if not published is None: - if isinstance(published, basestring): + if isinstance(published, string_types): published = dateutil.parser.parse(published) if not isinstance(published, datetime): raise ValueError('Invalid datetime format') diff --git a/feedgen/feed.py b/feedgen/feed.py index 9e3fc4c..bfe1717 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -17,6 +17,7 @@ from feedgen.util import ensure_format import feedgen.version import sys +from six import string_types _feedgen_version = feedgen.version.version_str @@ -431,7 +432,7 @@ def updated(self, updated=None): :returns: Modification date as datetime.datetime ''' if not updated is None: - if isinstance(updated, basestring): + if isinstance(updated, string_types): updated = dateutil.parser.parse(updated) if not isinstance(updated, datetime): raise ValueError('Invalid datetime format') @@ -670,9 +671,9 @@ def generator(self, generator=None, version=None, uri=None): ''' if not generator is None: self.__atom_generator = {'value':generator} - if not version in None: + if not version is None: self.__atom_generator['version'] = version - if not uri in None: + if not uri is None: self.__atom_generator['uri'] = uri self.__rss_generator = generator return self.__atom_generator @@ -846,7 +847,7 @@ def pubDate(self, pubDate=None): :returns: Publication date as datetime.datetime ''' if not pubDate is None: - if isinstance(pubDate, basestring): + if isinstance(pubDate, string_types): pubDate = dateutil.parser.parse(pubDate) if not isinstance(pubDate, datetime): raise ValueError('Invalid datetime format') diff --git a/setup.py b/setup.py index 059dcb7..87d87ef 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ url = 'http://lkiesow.github.io/python-feedgen', keywords = ['feed','ATOM','RSS','podcast'], license = 'FreeBSD and LGPLv3+', - install_requires = ['lxml', 'dateutils'], + install_requires = ['lxml', 'dateutils', 'six'], classifiers = [ 'Development Status :: 4 - Beta', 'Development Status :: 5 - Production/Stable', From f926aed49bd7bca3a87c1e95ddcc32b719c83079 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 3 Jan 2015 22:35:31 +0100 Subject: [PATCH 020/159] Added Python 3.4 for Tests Signed-off-by: Lars Kiesow --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 6824ca3..219a9a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: - "2.7" - "3.2" - "3.3" + - "3.4" before_install: pip install --quiet lxml python-dateutil From 01e3c0daaa74bcbc4b3048415446ec722a8c12f3 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 3 Jan 2015 23:13:55 +0100 Subject: [PATCH 021/159] Update Links to RPM Files Signed-off-by: Lars Kiesow --- readme.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index c50732e..38b4bb2 100644 --- a/readme.md +++ b/readme.md @@ -26,8 +26,14 @@ Installation If you are running Fedora Linux, Redhat Enterprise Linux, CentOS or Scientific Linux you can use one of the following packages: -- [python-feedgen-0.3.0-1.fc20.noarch.rpm](http://repo.virtuos.uos.de/repository/feedgen/python-feedgen-0.3.0-1.fc20.noarch.rpm) -- [python-feedgen-0.3.0-1.el6.noarch.rpm](http://repo.virtuos.uos.de/repository/feedgen/python-feedgen-0.3.0-1.el6.noarch.rpm) +- [python-feedgen-0.3.0-1.fc21.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.fc21.noarch.rpm) +- [python-feedgen-0.3.0-1.fc20.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.fc20.noarch.rpm) +- [python-feedgen-0.3.0-1.el7.centos.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.el7.centos.noarch.rpm) +- [python-feedgen-0.3.0-1.el6.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.el6.noarch.rpm) Simply download the file and run:: @@ -35,7 +41,8 @@ Simply download the file and run:: If you want to build RPMs for other distributions you can use the following Source RPM: -- [python-feedgen-0.3.0-1.fc20.src.rpm](http://repo.virtuos.uos.de/repository/feedgen/python-feedgen-0.3.0-1.fc20.src.rpm) +- [python-feedgen-0.3.0-1.fc20.src.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.fc21.src.rpm) **Using pip** From 7d1bda868dc769691dbc58e0a77ec89f0ffe8314 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sat, 3 Jan 2015 23:27:52 +0100 Subject: [PATCH 022/159] Fixed #24 Categories For Feed Entries Signed-off-by: Lars Kiesow --- feedgen/entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 00e4c94..09ad9bd 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -137,7 +137,7 @@ def atom_entry(self, extensions=True): summary.text = self.__atom_summary for c in self.__atom_category or []: - cat = etree.SubElement(feed, 'category', term=c['term']) + cat = etree.SubElement(entry, 'category', term=c['term']) if c.get('schema'): cat.attrib['schema'] = c['schema'] if c.get('label'): From bf8d0729f6a33f6c1b27d436d55992d7d0f7fb63 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 16 Jan 2015 17:05:54 +0100 Subject: [PATCH 023/159] Release 0.3.1 - Python 3 compatibility - Test updates - Encoding support Signed-off-by: Lars Kiesow --- feedgen/version.py | 4 ++-- python-feedgen.spec | 5 ++++- readme.md | 20 ++++++++++---------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index e8bf584..b88d846 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -3,14 +3,14 @@ feedgen.version ~~~~~~~~~~~~~~~ - :copyright: 2013-2014, Lars Kiesow + :copyright: 2013-2015, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' 'Version of python-feedgen represented as tuple' -version = (0, 3, 0) +version = (0, 3, 1) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 34453c5..5e373b0 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.3.0 +Version: 0.3.1 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -51,6 +51,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* 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 diff --git a/readme.md b/readme.md index 38b4bb2..f1a6a1e 100644 --- a/readme.md +++ b/readme.md @@ -26,14 +26,14 @@ Installation If you are running Fedora Linux, Redhat Enterprise Linux, CentOS or Scientific Linux you can use one of the following packages: -- [python-feedgen-0.3.0-1.fc21.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.fc21.noarch.rpm) -- [python-feedgen-0.3.0-1.fc20.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.fc20.noarch.rpm) -- [python-feedgen-0.3.0-1.el7.centos.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.el7.centos.noarch.rpm) -- [python-feedgen-0.3.0-1.el6.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.el6.noarch.rpm) +- [python-feedgen-0.3.1-1.fc21.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.fc21.noarch.rpm) +- [python-feedgen-0.3.1-1.fc20.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.fc20.noarch.rpm) +- [python-feedgen-0.3.1-1.el7.centos.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.el7.centos.noarch.rpm) +- [python-feedgen-0.3.1-1.el6.noarch.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.el6.noarch.rpm) Simply download the file and run:: @@ -41,8 +41,8 @@ Simply download the file and run:: If you want to build RPMs for other distributions you can use the following Source RPM: -- [python-feedgen-0.3.0-1.fc20.src.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.0-1.fc21.src.rpm) +- [python-feedgen-0.3.1-1.fc20.src.rpm + ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.fc21.src.rpm) **Using pip** From e4149d5c00d073e5bfe6677ce27f8c302dae732c Mon Sep 17 00:00:00 2001 From: Ken Sato Date: Sun, 8 Mar 2015 18:39:18 +0900 Subject: [PATCH 024/159] add syndication extension --- feedgen/ext/syndication.py | 60 +++++++++++++++++++++++++++++++++ feedgen/tests/test_extension.py | 50 +++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 feedgen/ext/syndication.py diff --git a/feedgen/ext/syndication.py b/feedgen/ext/syndication.py new file mode 100644 index 0000000..3b17e0c --- /dev/null +++ b/feedgen/ext/syndication.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2015 Kenichi Sato +# + +''' +Extends FeedGenerator to support Syndication module + +See below for details +http://web.resource.org/rss/1.0/modules/syndication/ +''' + +from lxml import etree +from feedgen.ext.base import BaseExtension + +SYNDICATION_NS = 'http://purl.org/rss/1.0/modules/syndication/' +PERIOD_TYPE = ('hourly', 'daily', 'weekly', 'monthly', 'yearly') + + +def _set_value(channel, name, value): + if value: + newelem = etree.SubElement(channel, '{%s}' % SYNDICATION_NS + name) + newelem.text = value + + +class SyndicationExtension(BaseExtension): + def __init__(self): + self._update_period = None + self._update_freq = None + self._update_base = None + + def extend_ns(self): + return {'sy': SYNDICATION_NS} + + def extend_rss(self, rss_feed): + channel = rss_feed[0] + _set_value(channel, 'UpdatePeriod', self._update_period) + _set_value(channel, 'UpdateFrequency', str(self._update_freq)) + _set_value(channel, 'UpdateBase', self._update_base) + + def update_period(self, value): + if value not in PERIOD_TYPE: + raise ValueError('Invalid update period value') + self._update_period = value + return self._update_period + + def update_frequency(self, value): + if type(value) is not int or value <= 0: + raise ValueError('Invalid update frequency value') + self._update_freq = value + return self._update_freq + + def update_base(self, value): + # the value should be in W3CDTF format + self._update_base = value + return self._update_base + + +class SyndicationEntryExtension(BaseExtension): + pass diff --git a/feedgen/tests/test_extension.py b/feedgen/tests/test_extension.py index e69de29..53333b7 100644 --- a/feedgen/tests/test_extension.py +++ b/feedgen/tests/test_extension.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +""" +Tests for extensions +""" + +import unittest +from ..feed import FeedGenerator +from lxml import etree + + +class TestExtensionSyndication(unittest.TestCase): + + 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={ + 'sy':'http://purl.org/rss/1.0/modules/syndication/' + }) + 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={ + 'sy':'http://purl.org/rss/1.0/modules/syndication/' + }) + 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/' + }) + assert a[0].text == base From 206e72cc23efd459cae45adb94d6dc5d7bb66e88 Mon Sep 17 00:00:00 2001 From: IGARASHI Masanao Date: Fri, 24 Apr 2015 21:43:29 +0900 Subject: [PATCH 025/159] Remove six dependency --- feedgen/compat.py | 7 +++++++ feedgen/entry.py | 2 +- feedgen/feed.py | 2 +- setup.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 feedgen/compat.py diff --git a/feedgen/compat.py b/feedgen/compat.py new file mode 100644 index 0000000..dc9127e --- /dev/null +++ b/feedgen/compat.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +import sys +if sys.version_info[0] >= 3: + string_types = str +else: + string_types = basestring diff --git a/feedgen/entry.py b/feedgen/entry.py index 09ad9bd..39b0577 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -13,7 +13,7 @@ import dateutil.parser import dateutil.tz from feedgen.util import ensure_format -from six import string_types +from feedgen.compat import string_types class FeedEntry(object): diff --git a/feedgen/feed.py b/feedgen/feed.py index bfe1717..dc89506 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -17,7 +17,7 @@ from feedgen.util import ensure_format import feedgen.version import sys -from six import string_types +from feedgen.compat import string_types _feedgen_version = feedgen.version.version_str diff --git a/setup.py b/setup.py index 87d87ef..059dcb7 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ url = 'http://lkiesow.github.io/python-feedgen', keywords = ['feed','ATOM','RSS','podcast'], license = 'FreeBSD and LGPLv3+', - install_requires = ['lxml', 'dateutils', 'six'], + install_requires = ['lxml', 'dateutils'], classifiers = [ 'Development Status :: 4 - Beta', 'Development Status :: 5 - Production/Stable', From 2f3c7dfe850fb2e1475cd0df38e3033c98c648ea Mon Sep 17 00:00:00 2001 From: IGARASHI Masanao Date: Fri, 24 Apr 2015 22:02:45 +0900 Subject: [PATCH 026/159] Fix typo, schema to scheme --- feedgen/entry.py | 10 +++++----- feedgen/feed.py | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 09ad9bd..6532e27 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -138,8 +138,8 @@ def atom_entry(self, extensions=True): for c in self.__atom_category or []: cat = etree.SubElement(entry, 'category', term=c['term']) - if c.get('schema'): - cat.attrib['schema'] = c['schema'] + if c.get('scheme'): + cat.attrib['scheme'] = c['scheme'] if c.get('label'): cat.attrib['label'] = c['label'] @@ -498,14 +498,14 @@ def category(self, category=None, replace=False, **kwargs): 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:schema is the + # 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('schema'): - rss_cat['domain'] = cat['schema'] + if cat.get('scheme'): + rss_cat['domain'] = cat['scheme'] self.__rss_category.append( rss_cat ) return self.__atom_category diff --git a/feedgen/feed.py b/feedgen/feed.py index bfe1717..b7ce1c6 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -44,7 +44,7 @@ def __init__(self): self.__atom_link = None # {href*, rel, type, hreflang, title, length} # optional - self.__atom_category = None # {term*, schema, label} + self.__atom_category = None # {term*, scheme, label} self.__atom_contributor = None self.__atom_generator = { 'value' :'python-feedgen', @@ -143,8 +143,8 @@ def _create_atom(self, extensions=True): for c in self.__atom_category or []: cat = etree.SubElement(feed, 'category', term=c['term']) - if c.get('schema'): - cat.attrib['schema'] = c['schema'] + if c.get('scheme'): + cat.attrib['scheme'] = c['scheme'] if c.get('label'): cat.attrib['label'] = c['label'] @@ -601,14 +601,14 @@ def category(self, category=None, replace=False, **kwargs): 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:schema is the + # 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('schema'): - rss_cat['domain'] = cat['schema'] + if cat.get('scheme'): + rss_cat['domain'] = cat['scheme'] self.__rss_category.append( rss_cat ) return self.__atom_category From 183cbd577acde169b34bb923f6b89cec463eb5f3 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 4 May 2015 18:51:35 +0200 Subject: [PATCH 027/159] Updated RPM SPEC File to Support Python 3 Signed-off-by: Lars Kiesow --- python-feedgen.spec | 57 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/python-feedgen.spec b/python-feedgen.spec index 5e373b0..7bdbc7e 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -2,7 +2,7 @@ Name: python-%{srcname} Version: 0.3.1 -Release: 1%{?dist} +Release: 2%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) Group: Development/Libraries @@ -17,6 +17,9 @@ BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) BuildArch: noarch BuildRequires: python2-devel BuildRequires: python-setuptools +BuildRequires: python3-devel +BuildRequires: python3-setuptools + Requires: python-lxml Requires: python-dateutil @@ -26,18 +29,49 @@ 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 +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. + + %prep %setup -q -n %{srcname}-%{version} +mkdir python2 +mv PKG-INFO docs feedgen license.bsd license.lgpl readme.md 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}|' %build -%{__python} setup.py build +pushd python2 +%{__python2} setup.py build +popd +pushd python3 +%{__python3} setup.py build +popd %install rm -rf $RPM_BUILD_ROOT -%{__python} setup.py install -O1 --skip-build --root $RPM_BUILD_ROOT -chmod 644 $RPM_BUILD_ROOT%{python_sitelib}/%{srcname}/*.py +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 @@ -46,11 +80,22 @@ rm -rf $RPM_BUILD_ROOT %files %defattr(-,root,root,-) -%doc docs/* -%{python_sitelib}/* +%license python2/license.* +%doc python2/docs/* +%{python2_sitelib}/* + + +%files -n python3-%{srcname} +%defattr(-,root,root,-) +%license python3/license.* +%doc python3/docs/* +%{python3_sitelib}/* %changelog +* 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 From 0aee3fd40727582e126fd35cfb3de0b3314e6105 Mon Sep 17 00:00:00 2001 From: Adam Parkin Date: Tue, 27 Oct 2015 08:50:13 -0700 Subject: [PATCH 028/159] Fixed domain not being set on RSS --- feedgen/entry.py | 4 ++-- feedgen/feed.py | 4 ++-- feedgen/tests/test_entry.py | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 09ad9bd..6b7f4ac 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -504,8 +504,8 @@ def category(self, category=None, replace=False, **kwargs): for cat in self.__atom_category: rss_cat = {} rss_cat['value'] = cat['label'] if cat.get('label') else cat['term'] - if cat.get('schema'): - rss_cat['domain'] = cat['schema'] + if cat.get('scheme'): + rss_cat['domain'] = cat['scheme'] self.__rss_category.append( rss_cat ) return self.__atom_category diff --git a/feedgen/feed.py b/feedgen/feed.py index bfe1717..88fa0fe 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -607,8 +607,8 @@ def category(self, category=None, replace=False, **kwargs): for cat in self.__atom_category: rss_cat = {} rss_cat['value'] = cat['label'] if cat.get('label') else cat['term'] - if cat.get('schema'): - rss_cat['domain'] = cat['schema'] + if cat.get('scheme'): + rss_cat['domain'] = cat['scheme'] self.__rss_category.append( rss_cat ) return self.__atom_category diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 7b35d60..4fa768e 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -75,3 +75,20 @@ def test_removeEntryByEntry(self): 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 'domain="http://www.somedomain.com/category"' in result From fab134e9bafc22901c230cb99cb3b38de98a50f2 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Wed, 28 Oct 2015 22:31:20 +0100 Subject: [PATCH 029/159] Added Copr Repository to Readme Signed-off-by: Lars Kiesow --- readme.md | 98 +++++++++++++++++++++++++------------------------------ 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/readme.md b/readme.md index f1a6a1e..43f0491 100644 --- a/readme.md +++ b/readme.md @@ -23,32 +23,22 @@ Installation **Prebuild packages** -If you are running Fedora Linux, Redhat Enterprise Linux, CentOS or Scientific -Linux you can use one of the following packages: +If you are running Fedora Linux, RedHat Enterprise Linux, CentOS or Scientific +Linux you can use the RPM Copr repostiory: -- [python-feedgen-0.3.1-1.fc21.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.fc21.noarch.rpm) -- [python-feedgen-0.3.1-1.fc20.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.fc20.noarch.rpm) -- [python-feedgen-0.3.1-1.el7.centos.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.el7.centos.noarch.rpm) -- [python-feedgen-0.3.1-1.el6.noarch.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.el6.noarch.rpm) +[http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/ +](http://copr.fedoraproject.org/coprs/lkiesow/python-feedgen/) -Simply download the file and run:: +Simply enable the repository and run: - $ yum localinstall python-feedgen-...noarch.rpm + $ yum install python-feedgen -If you want to build RPMs for other distributions you can use the following Source RPM: - -- [python-feedgen-0.3.1-1.fc20.src.rpm - ](http://data.larskiesow.de/feedgen/python-feedgen-0.3.1-1.fc21.src.rpm) **Using pip** You can also use pip to install the feedgen module. Simply run:: - $ pip install feedgen + $ pip install feedgen ------------- @@ -58,16 +48,16 @@ Create a Feed 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: @@ -78,9 +68,9 @@ feed you can use all of the following ways to provide data: 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 Doe', 'email':'jdoe@example.com'}, ...]) ----------------- Generate the Feed @@ -88,10 +78,10 @@ Generate the Feed 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 ---------------- @@ -103,11 +93,11 @@ 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 +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. @@ -118,7 +108,7 @@ Extensions 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 @@ -143,20 +133,20 @@ feed with some additional elements for ITunes. 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') - ... - >>> 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') + >>> from feedgen.feed import FeedGenerator + >>> fg = FeedGenerator() + >>> fg.load_extension('podcast') + ... + >>> 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') + ... + >>> 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 @@ -176,7 +166,7 @@ Testing the Generator You can test the module by simply executing:: - $ python -m feedgen + $ python -m feedgen 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 From 9819fd98b32af2624222b426d78c1966e5327c2d Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Jan 2015 00:13:57 +0100 Subject: [PATCH 030/159] Fixed #26, Fixed Localized Time Formatting Issues Signed-off-by: Lars Kiesow --- feedgen/entry.py | 5 ++--- feedgen/feed.py | 9 ++++----- feedgen/util.py | 12 +++++++++++- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index bf17d5f..bf50357 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -12,7 +12,7 @@ from datetime import datetime import dateutil.parser import dateutil.tz -from feedgen.util import ensure_format +from feedgen.util import ensure_format, formatRFC2822 from feedgen.compat import string_types @@ -220,8 +220,7 @@ def rss_entry(self, extensions=True): enclosure.attrib['type'] = self.__rss_enclosure['type'] if self.__rss_pubDate: pubDate = etree.SubElement(entry, 'pubDate') - pubDate.text = self.__rss_pubDate.strftime( - '%a, %d %b %Y %H:%M:%S %z') + pubDate.text = formatRFC2822(self.__rss_pubDate) if extensions: for ext in self.__extensions.values() or []: diff --git a/feedgen/feed.py b/feedgen/feed.py index ad38109..4c09dd3 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -14,7 +14,7 @@ import dateutil.parser import dateutil.tz from feedgen.entry import FeedEntry -from feedgen.util import ensure_format +from feedgen.util import ensure_format, formatRFC2822 import feedgen.version import sys from feedgen.compat import string_types @@ -313,15 +313,14 @@ def _create_rss(self, extensions=True): language.text = self.__rss_language if self.__rss_lastBuildDate: lastBuildDate = etree.SubElement(channel, 'lastBuildDate') - lastBuildDate.text = self.__rss_lastBuildDate.strftime( - '%a, %d %b %Y %H:%M:%S %z') + + 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 = self.__rss_pubDate.strftime( - '%a, %d %b %Y %H:%M:%S %z') + pubDate.text = formatRFC2822(self.__rss_pubDate) if self.__rss_rating: rating = etree.SubElement(channel, 'rating') rating.text = self.__rss_rating diff --git a/feedgen/util.py b/feedgen/util.py index 3f3d97e..c7c9454 100644 --- a/feedgen/util.py +++ b/feedgen/util.py @@ -8,7 +8,7 @@ :copyright: 2013, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' -import sys +import sys, locale def ensure_format(val, allowed, required, allowed_values=None, defaults=None): @@ -60,3 +60,13 @@ def ensure_format(val, allowed, required, allowed_values=None, defaults=None): 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 From abfd566c5b9c43a7895d122602b891e1fdb70227 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 29 Oct 2015 00:04:16 +0100 Subject: [PATCH 031/159] Fixed #28, Added Support for XML Declaration All XML generation methods now have an optional argument `xml_declaration` which, set to True, will add an XML declaration to the output. By default, the argument is set to True. Signed-off-by: Lars Kiesow --- feedgen/feed.py | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 4c09dd3..85e8a92 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -200,28 +200,40 @@ def _create_atom(self, extensions=True): return feed, doc - def atom_str(self, pretty=False, extensions=True, encoding="unicode"): + 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. ''' feed, doc = self._create_atom(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding) + 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"): + 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) + doc.write(filename, pretty_print=pretty, encoding=encoding, + xml_declaration=xml_declaration) def _create_rss(self, extensions=True): @@ -360,28 +372,40 @@ def _create_rss(self, extensions=True): return feed, doc - def rss_str(self, pretty=False, extensions=True, encoding="unicode"): + 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. ''' feed, doc = self._create_rss(extensions=extensions) - return etree.tostring(feed, pretty_print=pretty, encoding=encoding) + 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"): + 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) + doc.write(filename, pretty_print=pretty, encoding=encoding, + xml_declaration=xml_declaration) def title(self, title=None): From 86a8b6923312da97262c5d635df39fcbe05c48c2 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 29 Oct 2015 00:39:57 +0100 Subject: [PATCH 032/159] Fix Python 3 Test Issues Signed-off-by: Lars Kiesow --- feedgen/tests/test_entry.py | 2 +- feedgen/tests/test_feed.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index 4fa768e..d2fef27 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -91,4 +91,4 @@ def test_categoryHasDomain(self): }]) result = fg.rss_str() - assert 'domain="http://www.somedomain.com/category"' in result + assert b'domain="http://www.somedomain.com/category"' in result diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 757497c..33f82f7 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -120,7 +120,7 @@ def test_baseFeed(self): def test_atomFeedFile(self): fg = self.fg filename = 'tmp_Atomfeed.xml' - fg.atom_file(filename=filename, pretty=True) + fg.atom_file(filename=filename, pretty=True, xml_declaration=False) with open (filename, "r") as myfile: atomString=myfile.read().replace('\n', '') @@ -130,7 +130,7 @@ def test_atomFeedFile(self): def test_atomFeedString(self): fg = self.fg - atomString = fg.atom_str(pretty=True) + atomString = fg.atom_str(pretty=True, xml_declaration=False) self.checkAtomString(atomString) @@ -162,7 +162,7 @@ def checkAtomString(self, atomString): def test_rssFeedFile(self): fg = self.fg filename = 'tmp_Rssfeed.xml' - fg.rss_file(filename=filename, pretty=True) + fg.rss_file(filename=filename, pretty=True, xml_declaration=False) with open (filename, "r") as myfile: rssString=myfile.read().replace('\n', '') @@ -171,7 +171,7 @@ def test_rssFeedFile(self): def test_rssFeedString(self): fg = self.fg - rssString = fg.rss_str(pretty=True) + rssString = fg.rss_str(pretty=True, xml_declaration=False) self.checkRssString(rssString) def test_loadPodcastExtension(self): From ba89e283b22fd899aa14a6570bd09e17ef9e08eb Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 29 Oct 2015 00:45:14 +0100 Subject: [PATCH 033/159] Added Travis CI Build Status Image Signed-off-by: Lars Kiesow --- readme.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/readme.md b/readme.md index 43f0491..df8662c 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,9 @@ Feedgenerator ============= +[![Build Status](https://travis-ci.org/lkiesow/python-feedgen.svg?branch=master) +](https://travis-ci.org/lkiesow/python-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. From 345c0edf09e2ee39c9c634161b726913d19e2db5 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Thu, 29 Oct 2015 23:14:55 +0100 Subject: [PATCH 034/159] Release 0.3.2 - Syndication extension - Remove six dependency - Fix typo, schema to scheme - Fixed Localized Time Formatting Issues - Added Support for XML Declaration Signed-off-by: Lars Kiesow --- feedgen/version.py | 2 +- python-feedgen.spec | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index b88d846..70299ee 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 3, 1) +version = (0, 3, 2) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index 7bdbc7e..f6a7d46 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,8 +1,8 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.3.1 -Release: 2%{?dist} +Version: 0.3.2 +Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) Group: Development/Libraries @@ -93,6 +93,9 @@ rm -rf $RPM_BUILD_ROOT %changelog +* 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 From 7b9df194027a99dfc9c2f9b227f0cd431d8e5457 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 30 Oct 2015 01:03:26 +0100 Subject: [PATCH 035/159] Added Python 3 Package to Readme Signed-off-by: Lars Kiesow --- readme.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/readme.md b/readme.md index df8662c..0daef76 100644 --- a/readme.md +++ b/readme.md @@ -36,6 +36,10 @@ Simply enable the repository and run: $ yum install python-feedgen +or for the Python 3 package: + + $ yum install python3-feedgen + **Using pip** From 5caebc220c0c9ea9281b8be1da83716317bf6cfc Mon Sep 17 00:00:00 2001 From: julien Date: Thu, 3 Dec 2015 16:57:30 +0100 Subject: [PATCH 036/159] ref https://github.com/lkiesow/python-feedgen/issues/34 ref https://github.com/OnroerendErfgoed/postregistratie/issues/398 --- feedgen/feed.py | 14 ++++++++- feedgen/tests/test_feed.py | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 85e8a92..1d88bc3 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -584,7 +584,19 @@ def link(self, link=None, replace=False, **kwargs): self.__atom_link += ensure_format( link, set(['href', 'rel', 'type', 'hreflang', 'title', 'length']), set(['href']), - {'rel':['alternate', 'enclosure', 'related', 'self', 'via']} ) + {'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'] diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index 33f82f7..bcfe506 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -133,6 +133,64 @@ def test_atomFeedString(self): 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): From aee009678c33164115b03617375fd88ed2858953 Mon Sep 17 00:00:00 2001 From: Timothy Hopper Date: Thu, 31 Dec 2015 21:43:23 -0500 Subject: [PATCH 037/159] Fix spelling error --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 0daef76..c7c00df 100644 --- a/readme.md +++ b/readme.md @@ -133,7 +133,7 @@ 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: Producing a Podcast** One extension already provided is the podcast extension. A podcast is an RSS feed with some additional elements for ITunes. From 9b6e3cb45ec111d0c1293d7c943435343c182974 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Fri, 1 Jan 2016 19:31:00 +0100 Subject: [PATCH 038/159] Fix Python 2 Unicode Problem When piping to stdout the special handling for Pthon 3 will cause problems in Python 2 and produce an Unicode encoding error. This commit separates the output handling of both Python versions. Signed-off-by: Lars Kiesow --- feedgen/__main__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 24fd463..4463ea6 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -15,7 +15,10 @@ def print_enc(s): '''Print function compatible with both python2 and python3 accepting strings and byte arrays. ''' - print(s.decode('utf-8') if type(s) == type(b'') else s) + if sys.version_info[0] >= 3: + print(s.decode('utf-8') if type(s) == type(b'') else s) + else: + print(s) From 5724c569b18c7d619fa09293271ee7f0b95d2f1a Mon Sep 17 00:00:00 2001 From: Russel Mahmud Date: Thu, 3 Mar 2016 14:44:56 +0600 Subject: [PATCH 039/159] Fix atom content type CDATA --- feedgen/entry.py | 2 +- feedgen/tests/test_entry.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index bf50357..d618ebf 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -104,7 +104,7 @@ def atom_entry(self, extensions=True): xmlns="http://www.w3.org/1999/xhtml">%s''' % \ self.__atom_content.get('content'))) elif type == 'CDATA': - content.text = etree.CDATA(self.__atom_content) + 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') diff --git a/feedgen/tests/test_entry.py b/feedgen/tests/test_entry.py index d2fef27..8e4ca0d 100644 --- a/feedgen/tests/test_entry.py +++ b/feedgen/tests/test_entry.py @@ -92,3 +92,15 @@ def test_categoryHasDomain(self): 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 + From 4f48bc1213a387f6708cc7020b4ee7733ed31e70 Mon Sep 17 00:00:00 2001 From: jonathan vanasco Date: Tue, 8 Mar 2016 22:46:07 -0500 Subject: [PATCH 040/159] allowing pluggable extensions --- feedgen/entry.py | 41 +++++++++++++++++++++++++++ feedgen/feed.py | 73 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index bf50357..6b1c258 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -56,6 +56,7 @@ def __init__(self): # Extension list: self.__extensions = {} + self.__extensions_register = {} def atom_entry(self, extensions=True): @@ -654,3 +655,43 @@ def load_extension(self, name, atom=True, rss=True): extinst = ext() setattr(self, name, extinst) self.__extensions[name] = {'inst':extinst,'atom':atom,'rss':rss} + + def register_extension( + self, + namespace, + extension_class_feed=None, + 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_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') + + extinst = extension_class_entry() + setattr(self, namespace, extinst) + + # `load_extension` registry + self.__extensions[namespace] = {'inst': extinst, + 'atom': atom, + 'rss': rss + } + + # `register_extension` registry + self.__extensions_register[namespace] = { + 'extension_class_feed': extension_class_feed, + 'extension_class_entry': extension_class_entry, + 'atom': atom, + 'rss': rss, + } \ No newline at end of file diff --git a/feedgen/feed.py b/feedgen/feed.py index 1d88bc3..e79e7d9 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -29,7 +29,6 @@ class FeedGenerator(object): def __init__(self): - self.__extensions = {} self.__feed_entries = [] ## ATOM @@ -82,7 +81,8 @@ def __init__(self): self.__rss_webMaster = None # Extension list: - __extensions = {} + self.__extensions = {} + self.__extensions_register = {} def _create_atom(self, extensions=True): @@ -1024,7 +1024,14 @@ def add_entry(self, feedEntry=None): # Try to load extensions: for extname,ext in items: try: - feedEntry.load_extension( extname, ext['atom'], ext['rss'] ) + if extname in self.__extensions_register: + ext_reg = self.__extensions_register[extname] + feedEntry.register_extension(extname, + ext_reg['extension_class_feed'], + ext_reg['extension_class_entry'], + ext_reg['atom'], ext_reg['rss'] ) + else: + feedEntry.load_extension( extname, ext['atom'], ext['rss'] ) except ImportError: pass @@ -1066,7 +1073,14 @@ def entry(self, entry=None, replace=False): for e in entry: for extname,ext in items: try: - e.load_extension( extname, ext['atom'], ext['rss'] ) + if extname in self.__extensions_register: + ext_reg = self.__extensions_register[extname] + e.register_extension(extname, + ext_reg['extension_class_feed'], + ext_reg['extension_class_entry'], + ext_reg['atom'], ext_reg['rss'] ) + else: + e.load_extension( extname, ext['atom'], ext['rss'] ) except ImportError: pass @@ -1127,3 +1141,54 @@ def load_extension(self, name, atom=True, rss=True): entry.load_extension( name, atom, rss ) except ImportError: pass + + 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_entry() + setattr(self, namespace, extinst) + + # `load_extension` registry + self.__extensions[namespace] = {'inst':extinst, + 'atom':atom, + 'rss':rss + } + + # `register_extension` registry + self.__extensions_register[namespace] = { + '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, + extension_class_feed, + atom, rss) + except ImportError: + raise \ No newline at end of file From 086d51c55520e108776e5c74ca0f14b833186df4 Mon Sep 17 00:00:00 2001 From: xaxa Date: Mon, 21 Mar 2016 11:38:44 -0300 Subject: [PATCH 041/159] changes to support multiple itunes (sub)categories --- feedgen/ext/podcast.py | 58 +++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 616473e..36df92d 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -12,6 +12,7 @@ from lxml import etree from feedgen.ext.base import BaseExtension +from feedgen.util import ensure_format class PodcastExtension(BaseExtension): @@ -54,12 +55,17 @@ def extend_rss(self, rss_feed): block = etree.SubElement(channel, '{%s}block' % ITUNES_NS) block.text = 'yes' if self.__itunes_block else 'no' - if self.__itunes_category: - category = etree.SubElement(channel, '{%s}category' % ITUNES_NS) - category.attrib['text'] = self.__itunes_category['cat'] - if self.__itunes_category.get('sub'): + 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'] = self.__itunes_category['sub'] + subcategory.attrib['text'] = c.get('sub') if self.__itunes_image: image = etree.SubElement(channel, '{%s}image' % ITUNES_NS) @@ -120,27 +126,43 @@ def itunes_block(self, itunes_block=None): self.__itunes_block = itunes_block return self.__itunes_block - - def itunes_category(self, itunes_category=None, itunes_subcategory=None): + 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 - :param itunes_category: Category of the podcast. - :param itunes_subcategory: Subcategory of the podcast. - :returns: Category data of the podcast. + 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. + Like: [{"cat":"Arts","sub":"Design"},{"cat":"Arts","sub":"Food"}] + The code will be: + + + + + + + :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. ''' + if itunes_category is None and kwargs: + itunes_category = kwargs if not itunes_category is None: - if not itunes_category in self._itunes_categories.keys(): - raise ValueError('Invalid category') - cat = {'cat':itunes_category} - if not itunes_subcategory is None: - if not itunes_subcategory in self._itunes_categories[itunes_category]: - raise ValueError('Invalid subcategory') - cat['sub'] = itunes_subcategory - self.__itunes_category = cat + 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 From 6b7c44eeb3af0dad7717c0cb4618f2cd1746597b Mon Sep 17 00:00:00 2001 From: raspbeguy Date: Tue, 24 May 2016 00:46:11 +0200 Subject: [PATCH 042/159] Creation of torrent extention --- feedgen/ext/torrent.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 feedgen/ext/torrent.py diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py new file mode 100644 index 0000000..eab61fa --- /dev/null +++ b/feedgen/ext/torrent.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +''' + feedgen.ext.podcast + ~~~~~~~~~~~~~~~~~~~ + + Extends the FeedGenerator to produce torrent feeds. + + :copyright: 2016, Raspbeguy + + :license: FreeBSD and LGPL, see license.* for more details. +''' + +from lxml import etree +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} + + +class TorrentEntryExtension(BaseEntryExtension): + '''FeedEntry extention for torrent feeds + ''' + + + def __init__(self): + self.__torrent_enclosure = None + self.__torrent_media = None + self.__torrent_guid = 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_enclosure: + enclosure = etree.SubElement(entry, '{%s}enclosure' % TORRENT_NS) From 2b597a6dee10192faa6faabce4b7b14590b8da6c Mon Sep 17 00:00:00 2001 From: raspbeguy Date: Tue, 24 May 2016 15:48:18 +0200 Subject: [PATCH 043/159] finished torrent ext --- feedgen/ext/torrent.py | 89 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index eab61fa..8d8d9ca 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- ''' - feedgen.ext.podcast + feedgen.ext.torrent ~~~~~~~~~~~~~~~~~~~ Extends the FeedGenerator to produce torrent feeds. @@ -30,9 +30,11 @@ class TorrentEntryExtension(BaseEntryExtension): def __init__(self): - self.__torrent_enclosure = None - self.__torrent_media = None - self.__torrent_guid = None + self.__torrent_filename = None + self.__torrent_url = None + self.__torrent_hash = None + self.__torrent_length = None + self.__torrent_contentlength = None def extend_rss(self, entry): @@ -40,5 +42,80 @@ def extend_rss(self, entry): :param feed: The RSS item XML element to use. ''' - if self.__torrent_enclosure: - enclosure = etree.SubElement(entry, '{%s}enclosure' % TORRENT_NS) + enclosure = etree.SubElement(entry, '{%s}enclosure' % TORRENT_NS) + guid = etree.SubElement(entry, '{%s}guid' % TORRENT_NS) + torrent = etree.SubElement(entry, '{%s]torrent' % TORRENT_NS) + + enclosure.attrib['type'] = 'application/x-bittorrent' + + if self.__torrent_url: + enclosure.attrib['url'] = self.__torrent_url + guid.text = self.__torrent_url + + if self.__torrent_filename: + torrent_filename = etree.SubElement(torrent, '{%s}filename' % TORRENT_NS) + torrent_filename.text = self.__torrent_filename + + if self.__torrent_length: + enclosure.attrib['length'] = self.__torrent_length + + if self.__torrent_contentlength: + torrent_length = etree.SubElement(torrent, '{%s}contentlength' % TORRENT_NS) + torrent_length.text = self.__torrent_contentlength + + if self.__torrent_hash: + torrent_hash = etree.SubElement(torrent, '{%s}infohash' % TORRENT_NS) + torrent_hash.text = self.__torrent_hash + torrent.magnet = etree.SubElement(torrent, '{%s}magneturi' % TORRENT_NS) + torrent_magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_hash + + + def torrent_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 torrent_url (self, torrent_url=None): + '''Get or set the URL of the torrent. + + :param torrent_url: The torrent URL. + :returns: The torrent URL. + ''' + if not torrent_url is None: + self.__torrent_url = torrent_url + return self.__torrent_url + + def torrent_hash (self, torrent_hash=None): + '''Get or set the hash of the target file. + + :param torrent_url: The target file hash. + :returns: The target hash file. + ''' + if not torrent_hash is None: + self.__torrent_hash = torrent_hash + return self.__torrent_hash + + def torrent_length (self, torrent_length=None): + '''Get or set the size of the torrent file. + + :param torrent_length: The torrent size. + :returns: The torrent size. + ''' + if not torrent_length is None: + self.__torrent_length = torrent_length + return self.__torrent_length + + def torrent_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 From 60765741504c2373524d95fa186c3cf7b91fbe80 Mon Sep 17 00:00:00 2001 From: raspbeguy Date: Tue, 24 May 2016 17:45:38 +0200 Subject: [PATCH 044/159] fixed torrent ext, but still not doing what we want --- .gitignore | 2 ++ feedgen/ext/torrent.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index b6f6775..02bef52 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ feedgen/tests/tmp_Rssfeed.xml tmp_Atomfeed.xml tmp_Rssfeed.xml + +build/** diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index 8d8d9ca..09e54cb 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -44,7 +44,7 @@ def extend_rss(self, entry): ''' enclosure = etree.SubElement(entry, '{%s}enclosure' % TORRENT_NS) guid = etree.SubElement(entry, '{%s}guid' % TORRENT_NS) - torrent = etree.SubElement(entry, '{%s]torrent' % TORRENT_NS) + torrent = etree.SubElement(entry, '{%s}torrent' % TORRENT_NS) enclosure.attrib['type'] = 'application/x-bittorrent' @@ -66,7 +66,7 @@ def extend_rss(self, entry): if self.__torrent_hash: torrent_hash = etree.SubElement(torrent, '{%s}infohash' % TORRENT_NS) torrent_hash.text = self.__torrent_hash - torrent.magnet = etree.SubElement(torrent, '{%s}magneturi' % TORRENT_NS) + torrent_magnet = etree.SubElement(torrent, '{%s}magneturi' % TORRENT_NS) torrent_magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_hash From 94eabab02d3c030e9885e3a9a22da8e1a8a5e516 Mon Sep 17 00:00:00 2001 From: raspbeguy Date: Tue, 24 May 2016 23:56:32 +0200 Subject: [PATCH 045/159] Fixed torrent, added test and doc --- doc/api.rst | 1 + doc/ext/api.ext.torrent.rst | 7 +++ feedgen/__main__.py | 15 ++++- feedgen/ext/torrent.py | 122 +++++++++++++++++++----------------- setup.py | 4 +- 5 files changed, 88 insertions(+), 61 deletions(-) create mode 100644 doc/ext/api.ext.torrent.rst diff --git a/doc/api.rst b/doc/api.rst index 6aca66e..9991a6d 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -17,3 +17,4 @@ Contents: ext/api.ext.dc ext/api.ext.podcast ext/api.ext.podcast_entry + ext/api.ext.torrent diff --git a/doc/ext/api.ext.torrent.rst b/doc/ext/api.ext.torrent.rst new file mode 100644 index 0000000..cf2ee8b --- /dev/null +++ b/doc/ext/api.ext.torrent.rst @@ -0,0 +1,7 @@ +.. raw:: html + +
Contents
+
+ +.. automodule:: feedgen.ext.torrent + :members: diff --git a/feedgen/__main__.py b/feedgen/__main__.py index 4463ea6..dc527ac 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -26,8 +26,9 @@ def print_enc(s): 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 )' % \ + 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.') @@ -39,6 +40,7 @@ def print_enc(s): print_enc (' dc.rss -- Generate DC extension test output (rss format) and print it to stdout.') print_enc (' syndication.atom -- Generate DC extension test output (atom format) and print it to stdout.') print_enc (' syndication.rss -- Generate DC 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() @@ -90,6 +92,17 @@ def print_enc(s): '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') diff --git a/feedgen/ext/torrent.py b/feedgen/ext/torrent.py index 09e54cb..b7f5f49 100644 --- a/feedgen/ext/torrent.py +++ b/feedgen/ext/torrent.py @@ -18,8 +18,6 @@ class TorrentExtension(BaseExtension): '''FeedGenerator extension for torrent feeds. ''' - - def extend_ns(self): return {'torrent' : TORRENT_NS} @@ -27,50 +25,48 @@ def extend_ns(self): class TorrentEntryExtension(BaseEntryExtension): '''FeedEntry extention for torrent feeds ''' - - def __init__(self): - self.__torrent_filename = None - self.__torrent_url = None - self.__torrent_hash = None - self.__torrent_length = None - self.__torrent_contentlength = None - + 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. ''' - enclosure = etree.SubElement(entry, '{%s}enclosure' % TORRENT_NS) - guid = etree.SubElement(entry, '{%s}guid' % TORRENT_NS) - torrent = etree.SubElement(entry, '{%s}torrent' % TORRENT_NS) + if self.__torrent_filename: + filename = etree.SubElement(entry, '{%s}filename' % TORRENT_NS) + filename.text = self.__torrent_filename - enclosure.attrib['type'] = 'application/x-bittorrent' + if self.__torrent_contentlength: + contentlength = etree.SubElement(entry, '{%s}contentlength' % TORRENT_NS) + contentlength.text = self.__torrent_contentlength - if self.__torrent_url: - enclosure.attrib['url'] = self.__torrent_url - guid.text = self.__torrent_url - - if self.__torrent_filename: - torrent_filename = etree.SubElement(torrent, '{%s}filename' % TORRENT_NS) - torrent_filename.text = self.__torrent_filename + 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_length: - enclosure.attrib['length'] = self.__torrent_length + if self.__torrent_seeds: + seeds = etree.SubElement(entry, '{%s}seed' % TORRENT_NS) + seeds.text = self.__torrent_seeds - if self.__torrent_contentlength: - torrent_length = etree.SubElement(torrent, '{%s}contentlength' % TORRENT_NS) - torrent_length.text = self.__torrent_contentlength + if self.__torrent_peers: + peers = etree.SubElement(entry, '{%s}peers' % TORRENT_NS) + peers.text = self.__torrent_peers - if self.__torrent_hash: - torrent_hash = etree.SubElement(torrent, '{%s}infohash' % TORRENT_NS) - torrent_hash.text = self.__torrent_hash - torrent_magnet = etree.SubElement(torrent, '{%s}magneturi' % TORRENT_NS) - torrent_magnet.text = 'magnet:?xt=urn:btih:' + self.__torrent_hash + if self.__torrent_seeds: + verified = etree.SubElement(entry, '{%s}verified' % TORRENT_NS) + verified.text = self.__torrent_verified - def torrent_filename(self, torrent_filename=None): + def filename(self, torrent_filename=None): '''Get or set the name of the torrent file. :param torrent_filename: The name of the torrent file. @@ -80,37 +76,17 @@ def torrent_filename(self, torrent_filename=None): self.__torrent_filename = torrent_filename return self.__torrent_filename - def torrent_url (self, torrent_url=None): - '''Get or set the URL of the torrent. - - :param torrent_url: The torrent URL. - :returns: The torrent URL. - ''' - if not torrent_url is None: - self.__torrent_url = torrent_url - return self.__torrent_url - - def torrent_hash (self, torrent_hash=None): + def infohash (self, torrent_infohash=None): '''Get or set the hash of the target file. - :param torrent_url: The target file hash. + :param torrent_infohash: The target file hash. :returns: The target hash file. ''' - if not torrent_hash is None: - self.__torrent_hash = torrent_hash - return self.__torrent_hash + if not torrent_infohash is None: + self.__torrent_infohash = torrent_infohash + return self.__torrent_infohash - def torrent_length (self, torrent_length=None): - '''Get or set the size of the torrent file. - - :param torrent_length: The torrent size. - :returns: The torrent size. - ''' - if not torrent_length is None: - self.__torrent_length = torrent_length - return self.__torrent_length - - def torrent_contentlength (self, torrent_contentlength=None): + def contentlength (self, torrent_contentlength=None): '''Get or set the size of the target file. :param torrent_contentlength: The target file size. @@ -119,3 +95,33 @@ def torrent_contentlength (self, torrent_contentlength=None): 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 diff --git a/setup.py b/setup.py index 059dcb7..1dbdca4 100755 --- a/setup.py +++ b/setup.py @@ -8,11 +8,11 @@ name = 'feedgen', packages = ['feedgen', 'feedgen/ext'], version = feedgen.version.version_full_str, - description = 'Feed Generator (ATOM, RSS, Podcasts)', + description = 'Feed Generator (ATOM, RSS, Podcasts, torrent)', author = 'Lars Kiesow', author_email = 'lkiesow@uos.de', url = 'http://lkiesow.github.io/python-feedgen', - keywords = ['feed','ATOM','RSS','podcast'], + keywords = ['feed','ATOM','RSS','podcast','torrent'], license = 'FreeBSD and LGPLv3+', install_requires = ['lxml', 'dateutils'], classifiers = [ From 867e1c26fe63287ecca25371bbe202905772628d Mon Sep 17 00:00:00 2001 From: Nick Timkovich Date: Wed, 1 Jun 2016 20:50:48 -0500 Subject: [PATCH 046/159] Replace dateutils with python-dateutil --- .travis.yml | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 219a9a2..169bf25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - "3.2" - "3.3" - "3.4" + - "3.5" before_install: pip install --quiet lxml python-dateutil diff --git a/setup.py b/setup.py index 059dcb7..0df715e 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ url = 'http://lkiesow.github.io/python-feedgen', keywords = ['feed','ATOM','RSS','podcast'], license = 'FreeBSD and LGPLv3+', - install_requires = ['lxml', 'dateutils'], + install_requires = ['lxml', 'python-dateutil'], classifiers = [ 'Development Status :: 4 - Beta', 'Development Status :: 5 - Production/Stable', From 2e2c23d9ae007508feca2f948f5c0522da923ca2 Mon Sep 17 00:00:00 2001 From: Nick Timkovich Date: Wed, 1 Jun 2016 21:20:28 -0500 Subject: [PATCH 047/159] Add code to make wheels --- Makefile | 3 +++ setup.cfg | 2 ++ setup.py | 1 + 3 files changed, 6 insertions(+) create mode 100644 setup.cfg diff --git a/Makefile b/Makefile index b61b9b6..4ae3541 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,9 @@ doc-latexpdf: publish: sdist python setup.py register sdist upload +publish_wheel: sdist + python setup.py bdist_wheel upload + test: python -m unittest feedgen.tests.test_feed python -m unittest feedgen.tests.test_entry diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3c6e79c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py index 0df715e..a96055e 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import setuptools from distutils.core import setup import feedgen.version From 542dade8fa2ce88f5da95635b76c290af060f703 Mon Sep 17 00:00:00 2001 From: Nick Timkovich Date: Wed, 1 Jun 2016 21:33:23 -0500 Subject: [PATCH 048/159] Build and test wheel --- .travis.yml | 7 +++++-- Makefile | 6 +++--- setup.py | 14 +++++++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 169bf25..934b3bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,9 @@ python: - "3.4" - "3.5" -before_install: pip install --quiet lxml python-dateutil +before_install: + python setup.py bdist_wheel --include-test-subpackage + pip install dist/feedgen* -script: make test +script: + make test diff --git a/Makefile b/Makefile index 4ae3541..e9457b4 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ publish_wheel: sdist python setup.py bdist_wheel upload test: - python -m unittest feedgen.tests.test_feed - python -m unittest feedgen.tests.test_entry - python -m unittest feedgen.tests.test_extension + python -I -m unittest feedgen.tests.test_feed + python -I -m unittest feedgen.tests.test_entry + python -I -m unittest feedgen.tests.test_extension @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml diff --git a/setup.py b/setup.py index a96055e..fbe8984 100755 --- a/setup.py +++ b/setup.py @@ -1,13 +1,25 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import sys +MAGIC_BUILD_FLAG = '--include-test-subpackage' +if MAGIC_BUILD_FLAG in sys.argv: + sys.argv.remove(MAGIC_BUILD_FLAG) + with_tests = True +else: + with_tests = False + import setuptools from distutils.core import setup import feedgen.version +packages = ['feedgen', 'feedgen/ext'] +if with_tests: + packages.append('feedgen/tests') + setup( name = 'feedgen', - packages = ['feedgen', 'feedgen/ext'], + packages = packages, version = feedgen.version.version_full_str, description = 'Feed Generator (ATOM, RSS, Podcasts)', author = 'Lars Kiesow', From 743f9e4221b60826384c2d51d1db885782adfc60 Mon Sep 17 00:00:00 2001 From: Nick Timkovich Date: Wed, 1 Jun 2016 21:39:54 -0500 Subject: [PATCH 049/159] Fix .travis.yml settings --- .travis.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 934b3bd..7776087 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,8 +9,7 @@ python: - "3.5" before_install: - python setup.py bdist_wheel --include-test-subpackage - pip install dist/feedgen* + - python setup.py bdist_wheel --include-test-subpackage + - pip install dist/feedgen* -script: - make test +script: make test From a76a56e7396e2696bcb22551bd7f92755e7d0c02 Mon Sep 17 00:00:00 2001 From: Nick Timkovich Date: Wed, 1 Jun 2016 21:46:18 -0500 Subject: [PATCH 050/159] Compensate for no -I on <3.4 by just deleting the source dir for tests --- .travis.yml | 1 + Makefile | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7776087..8ea3b31 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,5 +11,6 @@ python: before_install: - python setup.py bdist_wheel --include-test-subpackage - pip install dist/feedgen* + - rm -rf feedgen script: make test diff --git a/Makefile b/Makefile index e9457b4..4ae3541 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ publish_wheel: sdist python setup.py bdist_wheel upload test: - python -I -m unittest feedgen.tests.test_feed - python -I -m unittest feedgen.tests.test_entry - python -I -m unittest feedgen.tests.test_extension + python -m unittest feedgen.tests.test_feed + python -m unittest feedgen.tests.test_entry + python -m unittest feedgen.tests.test_extension @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml From 95f3cb9fbe9c810a9b5791cdf3dad6a08a9a34d5 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 28 Aug 2016 18:11:10 +0200 Subject: [PATCH 051/159] Added Python 3.5 as test target --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 219a9a2..169bf25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - "3.2" - "3.3" - "3.4" + - "3.5" before_install: pip install --quiet lxml python-dateutil From 303e74dc7af67ca97411a26f24f83bcb71a05715 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 28 Aug 2016 20:54:11 +0200 Subject: [PATCH 052/159] Fixed registration of custom extensions This fixes some minor problems with the pull request from jvanasco so that it may be included into the next release: - Only one extension registry is used internally and custom extensions do not have to be stored two times. - Fix the bug that entry extensions were loaded for feeds. - Do not fail if there is only a feed extension. - Extensions for entries do not need a feed extension class. Signed-off-by: Lars Kiesow --- feedgen/entry.py | 43 ++++++------------ feedgen/feed.py | 93 +++++++++++++++----------------------- feedgen/tests/test_feed.py | 17 +++++++ 3 files changed, 67 insertions(+), 86 deletions(-) diff --git a/feedgen/entry.py b/feedgen/entry.py index 18d63ca..d1286c1 100644 --- a/feedgen/entry.py +++ b/feedgen/entry.py @@ -641,33 +641,22 @@ def load_extension(self, name, atom=True, rss=True): # Load extension extname = name[0].upper() + name[1:] + 'EntryExtension' - - # Try to import extension from dedicated module for entry: try: supmod = __import__('feedgen.ext.%s_entry' % name) extmod = getattr(supmod.ext, name + '_entry') except ImportError: - # Try the FeedExtension module instead + # 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) + - ext = getattr(extmod, extname) - extinst = ext() - setattr(self, name, extinst) - self.__extensions[name] = {'inst':extinst,'atom':atom,'rss':rss} - - def register_extension( - self, - namespace, - extension_class_feed=None, - extension_class_entry=None, - atom=True, - rss=True - ): + 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_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. @@ -678,20 +667,16 @@ def register_extension( 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, - 'atom': atom, - 'rss': rss - } - - # `register_extension` registry - self.__extensions_register[namespace] = { - 'extension_class_feed': extension_class_feed, - 'extension_class_entry': extension_class_entry, - 'atom': atom, - 'rss': rss, - } \ No newline at end of file + self.__extensions[namespace] = { + 'inst':extinst, + 'extension_class_entry': extension_class_entry, + 'atom':atom, + 'rss':rss + } diff --git a/feedgen/feed.py b/feedgen/feed.py index e79e7d9..98da277 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -82,7 +82,6 @@ def __init__(self): # Extension list: self.__extensions = {} - self.__extensions_register = {} def _create_atom(self, extensions=True): @@ -1024,14 +1023,9 @@ def add_entry(self, feedEntry=None): # Try to load extensions: for extname,ext in items: try: - if extname in self.__extensions_register: - ext_reg = self.__extensions_register[extname] - feedEntry.register_extension(extname, - ext_reg['extension_class_feed'], - ext_reg['extension_class_entry'], - ext_reg['atom'], ext_reg['rss'] ) - else: - feedEntry.load_extension( extname, ext['atom'], ext['rss'] ) + feedEntry.register_extension(extname, + ext['extension_class_entry'], + ext['atom'], ext['rss'] ) except ImportError: pass @@ -1073,14 +1067,9 @@ def entry(self, entry=None, replace=False): for e in entry: for extname,ext in items: try: - if extname in self.__extensions_register: - ext_reg = self.__extensions_register[extname] - e.register_extension(extname, - ext_reg['extension_class_feed'], - ext_reg['extension_class_entry'], - ext_reg['atom'], ext_reg['rss'] ) - else: - e.load_extension( extname, ext['atom'], ext['rss'] ) + e.register_extension(extname, + ext['extension_class_entry'], + ext['atom'], ext['rss'] ) except ImportError: pass @@ -1127,29 +1116,26 @@ def load_extension(self, name, atom=True, rss=True): raise ImportError('Extension already loaded') # Load extension - extname = name[0].upper() + name[1:] + 'Extension' - supmod = __import__('feedgen.ext.%s' % name) - extmod = getattr(supmod.ext, name) - ext = getattr(extmod, extname) - extinst = ext() - setattr(self, name, extinst) - self.__extensions[name] = {'inst':extinst,'atom':atom,'rss':rss} - - # Try to load the extension for already existing entries: - for entry in self.__feed_entries: - try: - entry.load_extension( name, atom, rss ) - except ImportError: - pass - - def register_extension( - self, - namespace, - extension_class_feed = None, - extension_class_entry = None, - atom=True, - rss=True - ): + 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 @@ -1166,29 +1152,22 @@ def register_extension( raise ImportError('Extension already loaded') # Load extension - extinst = extension_class_entry() + extinst = extension_class_feed() setattr(self, namespace, extinst) # `load_extension` registry - self.__extensions[namespace] = {'inst':extinst, - 'atom':atom, - 'rss':rss - } - - # `register_extension` registry - self.__extensions_register[namespace] = { - 'extension_class_feed': extension_class_feed, - 'extension_class_entry': extension_class_entry, - 'atom': atom, - 'rss': rss, - } + 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, - extension_class_feed, - atom, rss) + extension_class_entry, atom, rss) except ImportError: - raise \ No newline at end of file + pass diff --git a/feedgen/tests/test_feed.py b/feedgen/tests/test_feed.py index bcfe506..83245d3 100644 --- a/feedgen/tests/test_feed.py +++ b/feedgen/tests/test_feed.py @@ -10,6 +10,7 @@ import unittest from lxml import etree from ..feed import FeedGenerator +from ..ext.dc import DcExtension, DcEntryExtension class TestSequenceFunctions(unittest.TestCase): @@ -234,11 +235,27 @@ def test_rssFeedString(self): 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): From e571ab88c92f9e875f4878d1fd2e144bc93759f6 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 28 Aug 2016 21:24:01 +0200 Subject: [PATCH 053/159] Updated extension documentation Signed-off-by: Lars Kiesow --- readme.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index c7c00df..8f3916c 100644 --- a/readme.md +++ b/readme.md @@ -128,10 +128,9 @@ is required to have at least the two methods `extend_rss(...)` and 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` control if the extension is used for ATOM and +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** @@ -161,10 +160,17 @@ 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`. +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`. + +**Example: Producing a Podcast** + +If you want to load custom extension which are not part of the feedgen Python +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 a4bb9378390956e1da9f2c62b43cca8015a382a6 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 28 Aug 2016 21:25:53 +0200 Subject: [PATCH 054/159] Fixed readme header Signed-off-by: Lars Kiesow --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 8f3916c..b731173 100644 --- a/readme.md +++ b/readme.md @@ -165,7 +165,7 @@ 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`. -**Example: Producing a Podcast** +**Custom Extensions** If you want to load custom extension which are not part of the feedgen Python package, you can use the method `register_extension` instead. You can directly From 44cd92837dc17dd81e7a75ae351b99a807136549 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 29 Aug 2016 00:22:03 +0200 Subject: [PATCH 055/159] Update distribution setup Signed-off-by: Lars Kiesow --- .travis.yml | 3 +-- Makefile | 19 ++++++++++--------- setup.py | 14 +------------- {feedgen/tests => tests}/__init__.py | 0 {feedgen/tests => tests}/test_entry.py | 2 +- {feedgen/tests => tests}/test_extension.py | 2 +- {feedgen/tests => tests}/test_feed.py | 4 ++-- 7 files changed, 16 insertions(+), 28 deletions(-) rename {feedgen/tests => tests}/__init__.py (100%) rename {feedgen/tests => tests}/test_entry.py (98%) rename {feedgen/tests => tests}/test_extension.py (97%) rename {feedgen/tests => tests}/test_feed.py (99%) diff --git a/.travis.yml b/.travis.yml index 8ea3b31..c0d981b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,8 +9,7 @@ python: - "3.5" before_install: - - python setup.py bdist_wheel --include-test-subpackage + - python setup.py bdist_wheel - pip install dist/feedgen* - - rm -rf feedgen script: make test diff --git a/Makefile b/Makefile index 4ae3541..71bc310 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,20 @@ sdist: doc python setup.py sdist +bdist_wheel: doc + python setup.py bdist_wheel + clean: doc-clean @echo Removing binary files... @rm -f `find feedgen -name '*.pyc'` @rm -f `find feedgen -name '*.pyo'` + @rm -rf feedgen.egg-info/ build/ @echo Removing source distribution files... @rm -rf dist/ @rm -f MANIFEST @rm -f tmp_Atomfeed.xml tmp_Rssfeed.xml -doc: doc-clean doc-html doc-man doc-latexpdf +doc: doc-clean doc-html doc-man doc-clean: @echo Removing docs... @@ -41,14 +45,11 @@ doc-latexpdf: @echo 'Copying pdf to into docs dir' @cp doc/_build/latex/*.pdf docs/pdf/ -publish: sdist - python setup.py register sdist upload - -publish_wheel: sdist - python setup.py bdist_wheel upload +publish: + twine upload dist/* test: - python -m unittest feedgen.tests.test_feed - python -m unittest feedgen.tests.test_entry - python -m unittest feedgen.tests.test_extension + 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 diff --git a/setup.py b/setup.py index fbe8984..60c92ca 100755 --- a/setup.py +++ b/setup.py @@ -1,21 +1,10 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import sys -MAGIC_BUILD_FLAG = '--include-test-subpackage' -if MAGIC_BUILD_FLAG in sys.argv: - sys.argv.remove(MAGIC_BUILD_FLAG) - with_tests = True -else: - with_tests = False - -import setuptools -from distutils.core import setup +from setuptools import setup import feedgen.version packages = ['feedgen', 'feedgen/ext'] -if with_tests: - packages.append('feedgen/tests') setup( name = 'feedgen', @@ -29,7 +18,6 @@ license = 'FreeBSD and LGPLv3+', install_requires = ['lxml', 'python-dateutil'], classifiers = [ - 'Development Status :: 4 - Beta', 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', diff --git a/feedgen/tests/__init__.py b/tests/__init__.py similarity index 100% rename from feedgen/tests/__init__.py rename to tests/__init__.py diff --git a/feedgen/tests/test_entry.py b/tests/test_entry.py similarity index 98% rename from feedgen/tests/test_entry.py rename to tests/test_entry.py index 8e4ca0d..14ef25d 100644 --- a/feedgen/tests/test_entry.py +++ b/tests/test_entry.py @@ -8,7 +8,7 @@ import unittest from lxml import etree -from ..feed import FeedGenerator +from feedgen.feed import FeedGenerator class TestSequenceFunctions(unittest.TestCase): diff --git a/feedgen/tests/test_extension.py b/tests/test_extension.py similarity index 97% rename from feedgen/tests/test_extension.py rename to tests/test_extension.py index 53333b7..0c1762c 100644 --- a/feedgen/tests/test_extension.py +++ b/tests/test_extension.py @@ -5,7 +5,7 @@ """ import unittest -from ..feed import FeedGenerator +from feedgen.feed import FeedGenerator from lxml import etree diff --git a/feedgen/tests/test_feed.py b/tests/test_feed.py similarity index 99% rename from feedgen/tests/test_feed.py rename to tests/test_feed.py index 83245d3..7c70760 100644 --- a/feedgen/tests/test_feed.py +++ b/tests/test_feed.py @@ -9,8 +9,8 @@ import unittest from lxml import etree -from ..feed import FeedGenerator -from ..ext.dc import DcExtension, DcEntryExtension +from feedgen.feed import FeedGenerator +from feedgen.ext.dc import DcExtension, DcEntryExtension class TestSequenceFunctions(unittest.TestCase): From df164b37dbea2d00be844c94d0f09328a06c7cee Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Mon, 29 Aug 2016 01:07:42 +0200 Subject: [PATCH 056/159] Fixed documentation Signed-off-by: Lars Kiesow --- feedgen/feed.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feedgen/feed.py b/feedgen/feed.py index 98da277..d38afdb 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -538,6 +538,7 @@ def link(self, link=None, replace=False, **kwargs): 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 @@ -567,7 +568,8 @@ def link(self, link=None, replace=False, **kwargs): RSS only supports one link with URL only. :param link: Dict or list of dicts with data. - :param replace: Add or replace old data. + :param replace: If old links are to be replaced (default: False) + :returns: Current set of link data Example:: From 34b9c6f5ca303821f9ece50f4034b95fb6af8e50 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Sep 2016 15:59:18 +0200 Subject: [PATCH 057/159] Reset description Signed-off-by: Lars Kiesow --- .gitignore | 2 -- setup.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 02bef52..b6f6775 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,3 @@ feedgen/tests/tmp_Rssfeed.xml tmp_Atomfeed.xml tmp_Rssfeed.xml - -build/** diff --git a/setup.py b/setup.py index 509cca8..60c92ca 100755 --- a/setup.py +++ b/setup.py @@ -10,11 +10,11 @@ name = 'feedgen', packages = packages, version = feedgen.version.version_full_str, - description = 'Feed Generator (ATOM, RSS, Podcasts, torrent)', + 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','torrent'], + keywords = ['feed','ATOM','RSS','podcast'], license = 'FreeBSD and LGPLv3+', install_requires = ['lxml', 'python-dateutil'], classifiers = [ From 0246d8d1c8dceb47ecc81d81a07a0124aff620a3 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Sep 2016 16:02:17 +0200 Subject: [PATCH 058/159] Fixed help output Signed-off-by: Lars Kiesow --- feedgen/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/feedgen/__main__.py b/feedgen/__main__.py index dc527ac..ec2b779 100644 --- a/feedgen/__main__.py +++ b/feedgen/__main__.py @@ -3,7 +3,7 @@ feedgen ~~~~~~~ - :copyright: 2013, Lars Kiesow + :copyright: 2013-2016, Lars Kiesow :license: FreeBSD and LGPL, see license.* for more details. ''' @@ -38,8 +38,8 @@ def print_enc(s): 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 DC extension test output (atom format) and print it to stdout.') - print_enc (' syndication.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() From a9f561bff92b8bf16ad0c02505f0719d44793833 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Sep 2016 17:49:07 +0200 Subject: [PATCH 059/159] Added unit tests for Itunes extension Signed-off-by: Lars Kiesow --- tests/test_entry.py | 2 +- tests/test_extension.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_entry.py b/tests/test_entry.py index 14ef25d..a2055f2 100644 --- a/tests/test_entry.py +++ b/tests/test_entry.py @@ -92,7 +92,7 @@ def test_categoryHasDomain(self): 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') diff --git a/tests/test_extension.py b/tests/test_extension.py index 0c1762c..7443f5a 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -48,3 +48,27 @@ def test_update_base(self): 'sy':'http://purl.org/rss/1.0/modules/syndication/' }) 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(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' From b4400146eada370aa416fd4064c7558191a77c11 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Sep 2016 18:56:30 +0200 Subject: [PATCH 060/159] Fixed Podcast Extension API incompatibility This patch ensures the compatibility of setting categories for itunes podcasts even though the new API now supports setting multiple (sub-)categories. It also fixes the docs and adjusts them to explicitly mark the old syntax as deprecated. Signed-off-by: Lars Kiesow --- feedgen/ext/podcast.py | 40 ++++++++++++++++++++++++++++++++-------- tests/test_extension.py | 16 ++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/feedgen/ext/podcast.py b/feedgen/ext/podcast.py index 36df92d..44d5b39 100644 --- a/feedgen/ext/podcast.py +++ b/feedgen/ext/podcast.py @@ -13,6 +13,7 @@ from lxml import etree from feedgen.ext.base import BaseExtension from feedgen.util import ensure_format +from feedgen.compat import string_types class PodcastExtension(BaseExtension): @@ -134,28 +135,51 @@ def itunes_category(self, itunes_category=None, replace=False, **kwargs): 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 + If a podcast has more than one subcategory from the same category, the category is called more than once. - Like: [{"cat":"Arts","sub":"Design"},{"cat":"Arts","sub":"Food"}] - The code will be: - - - - + 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 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: diff --git a/tests/test_extension.py b/tests/test_extension.py index 7443f5a..b8e9991 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# vim: set et ts=4 sw=4 sts=4 sta tw=80 cc=81: """ Tests for extensions @@ -59,6 +60,21 @@ def setUp(self): 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') From 2d605c303b16a509dbec1f0e2b08db933b4f0ec2 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Sep 2016 19:52:55 +0200 Subject: [PATCH 061/159] Added note about *_str return type Signed-off-by: Lars Kiesow --- feedgen/feed.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/feedgen/feed.py b/feedgen/feed.py index d38afdb..18b5b18 100644 --- a/feedgen/feed.py +++ b/feedgen/feed.py @@ -211,6 +211,11 @@ def atom_str(self, pretty=False, extensions=True, encoding='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, @@ -383,6 +388,11 @@ def rss_str(self, pretty=False, extensions=True, encoding='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, From edcb01f0858a14cf53c0ed407fa774773b63f4be Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Sep 2016 20:31:44 +0200 Subject: [PATCH 062/159] Release 0.4.0 - Added support for custom extensions - Updated set-up utils - Added Torrent extensions - Added support for multiple ITunes (sub-)categories - Fixed CDATA content Thanks to all contributors for doing most of the work! Signed-off-by: Lars Kiesow --- feedgen/version.py | 2 +- python-feedgen.spec | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/feedgen/version.py b/feedgen/version.py index 70299ee..8bb558f 100644 --- a/feedgen/version.py +++ b/feedgen/version.py @@ -10,7 +10,7 @@ ''' 'Version of python-feedgen represented as tuple' -version = (0, 3, 2) +version = (0, 4, 0) 'Version of python-feedgen represented as string' diff --git a/python-feedgen.spec b/python-feedgen.spec index f6a7d46..9d4181a 100644 --- a/python-feedgen.spec +++ b/python-feedgen.spec @@ -1,7 +1,7 @@ %define srcname feedgen Name: python-%{srcname} -Version: 0.3.2 +Version: 0.4.0 Release: 1%{?dist} Summary: Feed Generator (ATOM, RSS, Podcasts) @@ -93,7 +93,10 @@ rm -rf $RPM_BUILD_ROOT %changelog -* Thu Oct 29 2015 Lars Kiesow 0.3.2-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 From 217e0842d012cb0ed50410fdd3821b0810369045 Mon Sep 17 00:00:00 2001 From: Lars Kiesow Date: Sun, 4 Sep 2016 21:51:41 +0200 Subject: [PATCH 063/159] Fixed TOC generation of docs Signed-off-by: Lars Kiesow --- doc/api.entry.rst | 1 + doc/api.feed.rst | 1 + doc/api.util.rst | 1 + doc/conf.py | 2 +- doc/ext/api.ext.base.rst | 1 + doc/ext/api.ext.dc.rst | 1 + doc/ext/api.ext.podcast.rst | 1 + doc/ext/api.ext.podcast_entry.rst | 1 + doc/ext/api.ext.torrent.rst | 1 + 9 files changed, 9 insertions(+), 1 deletion(-) diff --git a/doc/api.entry.rst b/doc/api.entry.rst index eab7d2f..bbc0c3a 100644 --- a/doc/api.entry.rst +++ b/doc/api.entry.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
diff --git a/doc/api.feed.rst b/doc/api.feed.rst index bc165c0..228d524 100644 --- a/doc/api.feed.rst +++ b/doc/api.feed.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
diff --git a/doc/api.util.rst b/doc/api.util.rst index 47747d5..8042022 100644 --- a/doc/api.util.rst +++ b/doc/api.util.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
diff --git a/doc/conf.py b/doc/conf.py index 0795320..ac1eb8f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -40,7 +40,7 @@ # General information about the project. project = u'python-feedgen' -copyright = u'2013, Lars Kiesow' +copyright = u'2013-2016, Lars Kiesow' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/doc/ext/api.ext.base.rst b/doc/ext/api.ext.base.rst index 0d6573c..047f752 100644 --- a/doc/ext/api.ext.base.rst +++ b/doc/ext/api.ext.base.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
diff --git a/doc/ext/api.ext.dc.rst b/doc/ext/api.ext.dc.rst index 7e01cf3..1700156 100644 --- a/doc/ext/api.ext.dc.rst +++ b/doc/ext/api.ext.dc.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
diff --git a/doc/ext/api.ext.podcast.rst b/doc/ext/api.ext.podcast.rst index 0fdcb0c..8aaf6d6 100644 --- a/doc/ext/api.ext.podcast.rst +++ b/doc/ext/api.ext.podcast.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
diff --git a/doc/ext/api.ext.podcast_entry.rst b/doc/ext/api.ext.podcast_entry.rst index 2ee1e0f..e713111 100644 --- a/doc/ext/api.ext.podcast_entry.rst +++ b/doc/ext/api.ext.podcast_entry.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
diff --git a/doc/ext/api.ext.torrent.rst b/doc/ext/api.ext.torrent.rst index cf2ee8b..ccfbc7b 100644 --- a/doc/ext/api.ext.torrent.rst +++ b/doc/ext/api.ext.torrent.rst @@ -1,5 +1,6 @@ .. raw:: html +
Contents
From 9f4431880e680e30c94446f0c2e4fc5213df19b7 Mon Sep 17 00:00:00 2001 From: Zhiming Wang Date: Mon, 19 Dec 2016 00:41:01 -0500 Subject: [PATCH 064/159] 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 065/159] 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 066/159] 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 067/159] 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 068/159] 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 069/159] 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 070/159] 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 071/159] 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 072/159] 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 073/159] 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 074/159] 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 075/159] 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 076/159] 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 077/159] 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 078/159] 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 079/159] 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 080/159] 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 081/159] 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 082/159] 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 083/159] 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 084/159] 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 085/159] 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 086/159] 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 087/159] 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 088/159] 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 089/159] 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 090/159] 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 091/159] 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 092/159] 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 093/159] 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 094/159] 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 095/159] 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 096/159] 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 097/159] 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 098/159] 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 099/159] 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 100/159] 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 101/159] 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 102/159] 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 103/159] 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 104/159] 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 105/159] 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 106/159] 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 107/159] =?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 108/159] 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 109/159] 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 110/159] 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 111/159] 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 112/159] 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 113/159] 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 114/159] 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 115/159] 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 116/159] 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 117/159] 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 118/159] 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 119/159] 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 120/159] 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 121/159] 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 122/159] 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 123/159] 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 124/159] 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 125/159] 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 126/159] 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 127/159] 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 128/159] 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 129/159] 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 130/159] 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 131/159] 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 132/159] 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 133/159] 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 134/159] 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 135/159] 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 136/159] 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 137/159] 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 138/159] 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 139/159] 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 140/159] 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 141/159] 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 142/159] 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 143/159] 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 144/159] 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 145/159] 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 146/159] 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 147/159] 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 148/159] 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 149/159] 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 150/159] 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 151/159] 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 152/159] 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 153/159] 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 154/159] 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 155/159] 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 156/159] 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 157/159] 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 158/159] 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 159/159] 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'